mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-03-19 18:02:58 +00:00
Compare commits
154 Commits
0.0.1
...
9ff22c76b0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff22c76b0 | ||
| 994099137a | |||
|
|
7a99534252 | ||
|
|
1f49598a1f | ||
|
|
13c7af276a | ||
|
|
cf6ab06762 | ||
|
|
1387e174d7 | ||
|
|
7bf58ed7d4 | ||
|
|
b607ad8af4 | ||
|
|
89d16ad90b | ||
|
|
d9eed9b972 | ||
|
|
e7b3b82816 | ||
|
|
894ac72908 | ||
|
|
6fcdcd2ea1 | ||
|
|
248e30cdf7 | ||
|
|
d8eda9d6bb | ||
|
|
928632f5bc | ||
|
|
e5f1ccb1b4 | ||
|
|
a078916f3c | ||
|
|
e4d15bd590 | ||
|
|
b2b3bae86b | ||
|
|
dd10f78aa8 | ||
|
|
e6d8b39fe7 | ||
|
|
6f0336eae4 | ||
|
|
407dd44725 | ||
|
|
7fe10fa4ce | ||
|
|
f1587ec640 | ||
|
|
99d08a6984 | ||
|
|
3c015d69e7 | ||
|
|
bb843d4b4a | ||
|
|
b47b9514a4 | ||
|
|
83b57a9846 | ||
|
|
b2444f5187 | ||
|
|
7c1aa2e218 | ||
|
|
31b5375aa0 | ||
|
|
6e089badde | ||
|
|
75641223b4 | ||
|
|
b8963713d4 | ||
|
|
04e803789c | ||
|
|
4b33faba40 | ||
|
|
5f5255ce16 | ||
|
|
550c89bf78 | ||
| 95b7ef6dd3 | |||
|
|
ea4a39a360 | ||
|
|
ea10379087 | ||
|
|
58fbb9907d | ||
|
|
6d7a23c21a | ||
|
|
32a399ea24 | ||
|
|
f101fd53ea | ||
| 87f0cdb44f | |||
|
|
f56685ce62 | ||
|
|
8d098eb35d | ||
|
|
66f24dbbb4 | ||
|
|
c9c67149e7 | ||
|
|
c7182e6d00 | ||
|
|
e05e1b5a58 | ||
|
|
a0f0844555 | ||
|
|
8cac3db14d | ||
|
|
644725cb70 | ||
|
|
0a5840cdfc | ||
|
|
aa3930a538 | ||
|
|
58098ef4c5 | ||
|
|
b80488307e | ||
|
|
7111746c27 | ||
|
|
0921746406 | ||
|
|
6eebf4a2e8 | ||
|
|
7e53128958 | ||
|
|
2b6ff1d2d8 | ||
|
|
ae5cc1efc3 | ||
|
|
65e33e14f2 | ||
|
|
35ca8e3cff | ||
|
|
bec064d0b7 | ||
|
|
3a122c3d0c | ||
|
|
f1a47c4830 | ||
|
|
745c48a1de | ||
|
|
74e384531c | ||
|
|
923f2d5914 | ||
|
|
5dd8f2507a | ||
|
|
f32bb1ddaf | ||
|
|
a10b9b6d61 | ||
|
|
a251a26da1 | ||
|
|
a03b132581 | ||
|
|
4361a0c9e5 | ||
|
|
74e92bc4a0 | ||
|
|
df3d8cbbd4 | ||
|
|
3068992e2b | ||
|
|
dd85c7a98d | ||
|
|
11387f6c97 | ||
|
|
7ea2d597cf | ||
|
|
743746f7ef | ||
|
|
4b71bc5409 | ||
|
|
eef03a3556 | ||
| 3f65b813bd | |||
|
|
958f5952a0 | ||
|
|
733ba1ed4f | ||
|
|
350106989c | ||
|
|
ffe17a6b1e | ||
|
|
395e6aa3de | ||
|
|
86e4234a97 | ||
|
|
860a6eed18 | ||
|
|
9ceac04762 | ||
|
|
1dfefac52b | ||
|
|
c182a91326 | ||
|
|
bae69a7774 | ||
|
|
3b25b5f680 | ||
|
|
823fdb4e27 | ||
|
|
bc28f837b3 | ||
|
|
608f8c1b6f | ||
|
|
259669b3d9 | ||
|
|
428e9cb977 | ||
|
|
9e3d1292df | ||
|
|
8244ee6ea4 | ||
|
|
d342bd2a0a | ||
|
|
df93c4b118 | ||
|
|
a61b1a4f57 | ||
|
|
6ff435da9d | ||
| d0f789e823 | |||
|
|
37210fa8d4 | ||
|
|
f2e9a2e0d8 | ||
|
|
4e4f617d95 | ||
|
|
02b68b12d7 | ||
|
|
f4ecb678c6 | ||
|
|
3477400602 | ||
|
|
87d0a78d38 | ||
|
|
dd84267f37 | ||
| eef3ab3761 | |||
| b369d317b1 | |||
| e35126856d | |||
|
|
3556b06bb9 | ||
|
|
7c738b75de | ||
|
|
460f90c03f | ||
|
|
4c0f17e53d | ||
| 1e4869b79c | |||
| 166e69fc63 | |||
|
|
89d8fc3f51 | ||
|
|
3619a59b3c | ||
|
|
ac7d7e3790 | ||
|
|
b13b9da7dd | ||
| 1d2b68f059 | |||
| c91bfa0ccf | |||
| 9a2bbda223 | |||
|
|
70fede7635 | ||
|
|
376dd21a8b | ||
| 72592fb559 | |||
| b017814c6d | |||
| ef2296d45e | |||
| d752b6d271 | |||
| 4b8d66c778 | |||
| 2aa2a94a38 | |||
|
|
48fa5ca422 | ||
|
|
0b31398443 | ||
|
|
d807e6d659 | ||
| 8a638f0f43 | |||
|
|
87855dcbf9 |
8
.checkmake
Normal file
8
.checkmake
Normal file
@@ -0,0 +1,8 @@
|
||||
# checkmake configuration
|
||||
# See: https://github.com/mrtazz/checkmake#configuration
|
||||
|
||||
[rules.timestampexpansion]
|
||||
disabled = true
|
||||
|
||||
[rules.maxbodylength]
|
||||
disabled = true
|
||||
38
.dockerignore
Normal file
38
.dockerignore
Normal file
@@ -0,0 +1,38 @@
|
||||
# Git
|
||||
.git
|
||||
.github
|
||||
.gitignore
|
||||
|
||||
# Build artifacts
|
||||
gibidify
|
||||
gibidify-*
|
||||
dist/
|
||||
coverage.out
|
||||
coverage.html
|
||||
test-results.json
|
||||
*.sarif
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Config and tooling
|
||||
.checkmake
|
||||
.editorconfig
|
||||
.golangci.yml
|
||||
.yamllint
|
||||
revive.toml
|
||||
|
||||
# Scripts
|
||||
scripts/
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -7,6 +7,31 @@ trim_trailing_whitespace = true
|
||||
indent_size = 2
|
||||
indent_style = tab
|
||||
tab_width = 2
|
||||
charset = utf-8
|
||||
|
||||
[*.go]
|
||||
max_line_length = 120
|
||||
|
||||
[*.{yml,yaml,json,example}]
|
||||
indent_style = space
|
||||
max_line_length = 250
|
||||
|
||||
[LICENSE]
|
||||
max_line_length = 80
|
||||
indent_size = 0
|
||||
indent_style = space
|
||||
|
||||
[*.{sh,md,txt}]
|
||||
indent_style = space
|
||||
|
||||
[.yamllint]
|
||||
indent_style = space
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 0
|
||||
max_line_length = 999
|
||||
tab_width = 4
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
14
.editorconfig-checker.json
Normal file
14
.editorconfig-checker.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Exclude": [".git", "vendor", "node_modules", "README\\.md"],
|
||||
"AllowedContentTypes": [],
|
||||
"PassedFiles": [],
|
||||
"Disable": {
|
||||
"IndentSize": false,
|
||||
"EndOfLine": false,
|
||||
"InsertFinalNewline": false,
|
||||
"TrimTrailingWhitespace": false,
|
||||
"MaxLineLength": false
|
||||
},
|
||||
"SpacesAfterTabs": false,
|
||||
"NoColor": false
|
||||
}
|
||||
16
.github/actions/setup/action.yml
vendored
Normal file
16
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: "Setup Go with Runner Hardening"
|
||||
description: "Reusable action to set up Go"
|
||||
inputs:
|
||||
token:
|
||||
description: "GitHub token for checkout (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
|
||||
with:
|
||||
go-version-file: ".go-version"
|
||||
cache: true
|
||||
4
.github/renovate.json
vendored
4
.github/renovate.json
vendored
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"github>ivuorinen/renovate-config"
|
||||
]
|
||||
"extends": ["github>ivuorinen/renovate-config"]
|
||||
}
|
||||
|
||||
164
.github/workflows/build-test-publish.yml
vendored
Normal file
164
.github/workflows/build-test-publish.yml
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
---
|
||||
name: Build, Test, Coverage, and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests with Coverage and SARIF
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
statuses: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download dependencies
|
||||
shell: bash
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
go test -race -covermode=atomic -json -coverprofile=coverage.out ./... | tee test-results.json
|
||||
|
||||
- name: Check coverage
|
||||
id: coverage
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ ! -f coverage.out ]]; then
|
||||
echo "coverage.out is missing; tests likely failed before producing coverage"
|
||||
exit 1
|
||||
fi
|
||||
coverage="$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}')"
|
||||
echo "total_coverage=$coverage" >> "$GITHUB_ENV"
|
||||
echo "Coverage: $coverage%"
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results.json
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash
|
||||
run: rm -f coverage.out test-results.json
|
||||
|
||||
- name: Fail if coverage is below threshold
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -z "${total_coverage:-}" ]]; then
|
||||
echo "total_coverage is unset; previous step likely failed"
|
||||
exit 1
|
||||
fi
|
||||
awk -v cov="$total_coverage" 'BEGIN{ if (cov < 60) exit 1; else exit 0 }' || {
|
||||
echo "Coverage ($total_coverage%) is below the threshold (60%)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
build:
|
||||
name: Build Binaries
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Build binary for ${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build \
|
||||
-ldflags "-X main.Version=${{ github.ref_name }}" \
|
||||
-o dist/gibidify-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} \
|
||||
.
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd dist
|
||||
for f in gibidify-*; do
|
||||
sha256sum "$f" > "$f.sha256"
|
||||
done
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: gibidify-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/*
|
||||
|
||||
docker:
|
||||
name: Build and Publish Docker Image
|
||||
if: github.event_name == 'release'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: |
|
||||
echo "${{ github.token }}" | docker login ghcr.io \
|
||||
-u "$(echo "${{ github.actor }}" | tr '[:upper:]' '[:lower:]')" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
repo="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||
docker buildx build --platform linux/amd64 \
|
||||
--tag "ghcr.io/${repo}/gibidify:${{ github.ref_name }}" \
|
||||
--tag "ghcr.io/${repo}/gibidify:latest" \
|
||||
--push .
|
||||
63
.github/workflows/ci.yml
vendored
63
.github/workflows/ci.yml
vendored
@@ -1,63 +0,0 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ created ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Binaries
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
goos: [ "linux", "darwin" ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build binary for ${{ matrix.goos }}
|
||||
shell: bash
|
||||
run: |
|
||||
GOOS=${{ matrix.goos }} GOARCH=amd64 go build \
|
||||
-ldflags "-X main.Version=dev-$(date -u +%Y%m%d%H%M)" \
|
||||
-o gibidify-${{ matrix.goos }} \
|
||||
.
|
||||
|
||||
- name: Upload artifact for ${{ matrix.goos }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gibidify-${{ matrix.goos }}
|
||||
path: gibidify-${{ matrix.goos }}
|
||||
|
||||
docker:
|
||||
name: Build and Publish Docker Image
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Linux binary artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: gibidify-linux
|
||||
path: .
|
||||
|
||||
- name: Build Docker image
|
||||
shell: bash
|
||||
run: |
|
||||
cp ./gibidify-linux ./gibidify
|
||||
chmod +x ./gibidify
|
||||
docker build -t ghcr.io/${{ github.repository }}/gibidify:${{ github.ref_name }} .
|
||||
40
.github/workflows/codeql.yml
vendored
Normal file
40
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: CodeQL Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze Code
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: go
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
32
.github/workflows/pr-lint.yml
vendored
Normal file
32
.github/workflows/pr-lint.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: PR Lint
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
branches: [master, main]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
name: PR Lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
- uses: ivuorinen/actions/pr-lint@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
97
.github/workflows/security.yml
vendored
Normal file
97
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
schedule:
|
||||
# Run security scan weekly on Sundays at 00:00 UTC
|
||||
- cron: "0 0 * * 0"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
security:
|
||||
name: Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
|
||||
# Security Scanning with gosec
|
||||
- name: Run gosec Security Scanner
|
||||
uses: securego/gosec@424fc4cd9c82ea0fd6bee9cd49c2db2c3cc0c93f # v2.22.11
|
||||
with:
|
||||
args: "-fmt sarif -out gosec-results.sarif ./..."
|
||||
|
||||
- name: Upload gosec results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: gosec-results.sarif
|
||||
|
||||
# Dependency Vulnerability Scanning
|
||||
- name: Run govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck -json ./... > govulncheck-results.json || true
|
||||
|
||||
- name: Parse govulncheck results
|
||||
run: |
|
||||
if [ -s govulncheck-results.json ]; then
|
||||
echo "::warning::Vulnerability check completed. Check govulncheck-results.json for details."
|
||||
if grep -i -q '"finding"' govulncheck-results.json; then
|
||||
echo "::error::Vulnerabilities found in dependencies!"
|
||||
cat govulncheck-results.json
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Makefile Linting
|
||||
- name: Run checkmake on Makefile
|
||||
run: |
|
||||
go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
checkmake --config=.checkmake Makefile
|
||||
|
||||
# Shell Script Formatting Check
|
||||
- name: Check shell script formatting
|
||||
run: |
|
||||
go install mvdan.cc/sh/v3/cmd/shfmt@latest
|
||||
shfmt -d .
|
||||
|
||||
- name: Run YAML linting
|
||||
uses: ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c # v3.1.1
|
||||
with:
|
||||
file_or_dir: .
|
||||
strict: true
|
||||
|
||||
# Docker Security (if Dockerfile exists)
|
||||
- name: Run Docker security scan
|
||||
if: hashFiles('Dockerfile') != ''
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/workspace \
|
||||
aquasec/trivy:latest fs --security-checks vuln,config /workspace/Dockerfile || true
|
||||
|
||||
# Upload artifacts for review
|
||||
- name: Upload security scan results
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: security-scan-results
|
||||
path: |
|
||||
gosec-results.sarif
|
||||
govulncheck-results.json
|
||||
retention-days: 30
|
||||
25
.github/workflows/sync-labels.yml
vendored
Normal file
25
.github/workflows/sync-labels.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
name: Sync labels
|
||||
|
||||
permissions: read-all
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/sync-labels.yml
|
||||
- .github/labels.yml
|
||||
schedule:
|
||||
- cron: "34 5 * * *"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
SyncLabels:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: ivuorinen/actions/sync-labels@f98ae7cd7d0feb1f9d6b01de0addbb11414cfc73 # v2026.01.21
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -1,9 +1,21 @@
|
||||
*.out
|
||||
.DS_Store
|
||||
.idea
|
||||
.serena/
|
||||
coverage.*
|
||||
gibidify
|
||||
gibidify-benchmark
|
||||
gibidify.json
|
||||
gibidify.txt
|
||||
gibidify.yaml
|
||||
megalinter-reports/*
|
||||
output.json
|
||||
output.txt
|
||||
output.yaml
|
||||
gosec-report.json
|
||||
govulncheck-report.json
|
||||
gitleaks-report.json
|
||||
security-report.json
|
||||
security-report.md
|
||||
gosec*.log
|
||||
pr.txt
|
||||
|
||||
15
.gitleaks.toml
Normal file
15
.gitleaks.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
# gitleaks configuration
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
#
|
||||
# Extends the built-in ruleset. Only allowlist overrides are defined here.
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist for generated and report files"
|
||||
paths = [
|
||||
'''gosec-report\.json$''',
|
||||
'''govulncheck-report\.json$''',
|
||||
'''security-report\.json$''',
|
||||
'''security-report\.md$''',
|
||||
'''output\.json$''',
|
||||
'''gibidify\.json$''',
|
||||
]
|
||||
1
.go-version
Normal file
1
.go-version
Normal file
@@ -0,0 +1 @@
|
||||
1.25.6
|
||||
25
.mega-linter.yml
Normal file
25
.mega-linter.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
# Configuration file for MegaLinter
|
||||
# See all available variables at
|
||||
# https://megalinter.io/configuration/ and in linters documentation
|
||||
|
||||
APPLY_FIXES: all
|
||||
SHOW_ELAPSED_TIME: false # Show elapsed time at the end of MegaLinter run
|
||||
PARALLEL: true
|
||||
VALIDATE_ALL_CODEBASE: true
|
||||
FILEIO_REPORTER: false # Generate file.io report
|
||||
GITHUB_STATUS_REPORTER: true # Generate GitHub status report
|
||||
IGNORE_GENERATED_FILES: true # Ignore generated files
|
||||
JAVASCRIPT_DEFAULT_STYLE: prettier # Default style for JavaScript
|
||||
PRINT_ALPACA: false # Print Alpaca logo in console
|
||||
SARIF_REPORTER: true # Generate SARIF report
|
||||
SHOW_SKIPPED_LINTERS: false # Show skipped linters in MegaLinter log
|
||||
|
||||
DISABLE_LINTERS:
|
||||
- REPOSITORY_DEVSKIM
|
||||
- REPOSITORY_TRIVY
|
||||
- GO_GOLANGCI_LINT
|
||||
- YAML_PRETTIER
|
||||
|
||||
# By default megalinter uses list_of_files, which is wrong.
|
||||
GO_REVIVE_CLI_LINT_MODE: project
|
||||
27
.pre-commit-config.yaml
Normal file
27
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json
|
||||
# For more hooks, see https://pre-commit.com/hooks.html
|
||||
repos:
|
||||
- repo: https://github.com/editorconfig-checker/editorconfig-checker.python
|
||||
rev: 3.6.0
|
||||
hooks:
|
||||
- id: editorconfig-checker
|
||||
alias: ec
|
||||
|
||||
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||
rev: v1.0.0-rc.2
|
||||
hooks:
|
||||
- id: go-build-mod
|
||||
alias: build
|
||||
- id: go-mod-tidy
|
||||
alias: tidy
|
||||
- id: go-revive
|
||||
alias: revive
|
||||
- id: go-vet-mod
|
||||
alias: vet
|
||||
- id: go-staticcheck-mod
|
||||
alias: static
|
||||
- id: go-fmt
|
||||
alias: fmt
|
||||
- id: go-sec-mod
|
||||
alias: sec
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
||||
74
.serena/project.yml
Normal file
74
.serena/project.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
---
|
||||
language: go
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location
|
||||
# (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given
|
||||
# name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active
|
||||
# and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks,
|
||||
# e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation
|
||||
# (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on
|
||||
# track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "gibidify"
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "AquaSecurityOfficial.trivy-vulnerability-scanner", "Bridgecrew.checkov", "exiasr.hadolint", "ms-vscode.Go", "streetsidesoftware.code-spell-checker"]
|
||||
}
|
||||
18
.yamlfmt.yml
Normal file
18
.yamlfmt.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
doublestar: true
|
||||
gitignore_excludes: true
|
||||
formatter:
|
||||
type: basic
|
||||
include_document_start: true
|
||||
retain_line_breaks_single: true
|
||||
scan_folded_as_literal: false
|
||||
max_line_length: 0
|
||||
trim_trailing_whitespace: true
|
||||
array_indent: 2
|
||||
force_array_style: block
|
||||
include:
|
||||
- ./**/*.yml
|
||||
- ./**/*.yaml
|
||||
- .github/**/*.yml
|
||||
- .github/**/*.yaml
|
||||
# exclude:
|
||||
38
.yamllint
Normal file
38
.yamllint
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# yamllint configuration
|
||||
# See: https://yamllint.readthedocs.io/en/stable/configuration.html
|
||||
|
||||
extends: default
|
||||
|
||||
# Ignore generated output files
|
||||
ignore: |
|
||||
gibidify.yaml
|
||||
gibidify.yml
|
||||
output.yaml
|
||||
output.yml
|
||||
|
||||
rules:
|
||||
# Allow longer lines for URLs and commands in GitHub Actions
|
||||
line-length:
|
||||
max: 120
|
||||
level: warning
|
||||
|
||||
# Allow 2-space indentation to match EditorConfig
|
||||
indentation:
|
||||
spaces: 2
|
||||
indent-sequences: true
|
||||
check-multi-line-strings: false
|
||||
|
||||
# Allow truthy values like 'on' in GitHub Actions
|
||||
truthy:
|
||||
allowed-values: ['true', 'false', 'on', 'off']
|
||||
check-keys: false
|
||||
|
||||
# Allow empty values in YAML
|
||||
empty-values:
|
||||
forbid-in-block-mappings: false
|
||||
forbid-in-flow-mappings: false
|
||||
|
||||
# Relax comments formatting
|
||||
comments:
|
||||
min-spaces-from-content: 1
|
||||
76
CLAUDE.md
Normal file
76
CLAUDE.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Go CLI aggregating code files into LLM-optimized output.
|
||||
Supports markdown/JSON/YAML with concurrent processing.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Core**: `main.go`, `cli/`, `fileproc/`, `config/`, `utils/`, `testutil/`, `cmd/`
|
||||
|
||||
**Advanced**: `metrics/`, `templates/`, `benchmark/`
|
||||
|
||||
**Modules**: Collection, processing, writers, registry (~63ns cache), resource limits, metrics, templating
|
||||
|
||||
**Patterns**: Producer-consumer, thread-safe registry, streaming, modular (50-200 lines)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make lint-fix && make lint && make test
|
||||
./gibidify -source <dir> -format markdown --verbose
|
||||
./gibidify -source <dir> -format json --log-level debug --verbose
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
`~/.config/gibidify/config.yaml`
|
||||
Size limit 5MB, ignore dirs, custom types, 100MB memory limit
|
||||
|
||||
## Linting Standards (MANDATORY)
|
||||
|
||||
**Linter**: revive (comprehensive rule set migrated from golangci-lint)
|
||||
**Command**: `revive -config revive.toml ./...`
|
||||
**Complexity**: cognitive-complexity ≤15, cyclomatic ≤15, max-control-nesting ≤5
|
||||
**Security**: unhandled errors, secure coding patterns, credential detection
|
||||
**Performance**: optimize-operands-order, string-format, range optimizations
|
||||
**Format**: line-length ≤120 chars, EditorConfig (LF, tabs), gofmt/goimports
|
||||
**Testing**: error handling best practices, 0 tolerance policy
|
||||
|
||||
**CRITICAL**: All rules non-negotiable. `make lint-fix && make lint` must show 0 issues.
|
||||
|
||||
## Testing
|
||||
|
||||
**Coverage**: 77.9% overall (utils 90.0%, cli 83.8%, config 77.0%, testutil 73.7%, fileproc 74.5%, metrics 96.0%, templates 87.3%)
|
||||
**Patterns**: Table-driven tests, shared testutil helpers, mock objects, error assertions
|
||||
**Race detection**, benchmarks, comprehensive integration tests
|
||||
|
||||
## Development Patterns
|
||||
|
||||
**Logging**: Use `utils.Logger()` for all logging (replaces logrus). Default WARN level, set via `--log-level` flag
|
||||
**Error Handling**: Use `utils.WrapError` family for structured errors with context
|
||||
**Streaming**: Use `utils.StreamContent/StreamLines` for consistent file processing
|
||||
**Context**: Use `utils.CheckContextCancellation` for standardized cancellation
|
||||
**Testing**: Use `testutil.*` helpers for directory setup, error assertions
|
||||
**Validation**: Centralized in `config/validation.go` with structured error collection
|
||||
|
||||
## Standards
|
||||
|
||||
EditorConfig (LF, tabs), semantic commits, testing required, error wrapping
|
||||
|
||||
## revive.toml Restrictions
|
||||
|
||||
**AGENTS DO NOT HAVE PERMISSION** to modify `revive.toml` configuration unless user explicitly requests it.
|
||||
The linting configuration is carefully tuned and should not be altered during normal development.
|
||||
|
||||
## Status
|
||||
|
||||
**Health: 9/10** - Production-ready with systematic deduplication complete
|
||||
|
||||
**Done**: Deduplication, errors, benchmarks, config, optimization, testing (77.9%), modularization, linting (0 issues), metrics system, templating
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `make lint-fix` first
|
||||
2. >80% coverage
|
||||
3. Follow patterns
|
||||
4. Update docs
|
||||
@@ -1,5 +1,11 @@
|
||||
# Use a minimal base image
|
||||
FROM alpine:latest
|
||||
FROM alpine:3.23.3
|
||||
|
||||
# Add user
|
||||
RUN useradd -ms /bin/bash gibidify
|
||||
|
||||
# Use the new user
|
||||
USER gibidify
|
||||
|
||||
# Copy the gibidify binary into the container
|
||||
COPY gibidify /usr/local/bin/gibidify
|
||||
|
||||
19
LICENSE
19
LICENSE
@@ -1,7 +1,20 @@
|
||||
MIT License Copyright (c) 2025 Ismo Vuorinen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 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.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
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.
|
||||
|
||||
131
Makefile
Normal file
131
Makefile
Normal file
@@ -0,0 +1,131 @@
|
||||
.PHONY: all help install-tools lint lint-fix test coverage build clean all build-benchmark benchmark benchmark-go benchmark-go-cli benchmark-go-fileproc benchmark-go-metrics benchmark-go-shared benchmark-all benchmark-collection benchmark-processing benchmark-concurrency benchmark-format security security-full vuln-check update-deps check-all dev-setup
|
||||
|
||||
# Default target shows help
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# All target runs full workflow
|
||||
all: lint lint-fix test build
|
||||
|
||||
# Help target
|
||||
help:
|
||||
@cat scripts/help.txt
|
||||
|
||||
# Install required tools
|
||||
install-tools:
|
||||
@./scripts/install-tools.sh
|
||||
|
||||
# Run linters
|
||||
lint:
|
||||
@./scripts/lint.sh
|
||||
|
||||
# Run linters with auto-fix
|
||||
lint-fix:
|
||||
@./scripts/lint-fix.sh
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@go test -race -v ./...
|
||||
|
||||
# Run tests with coverage
|
||||
coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
@go test -race -coverprofile=coverage.out -covermode=atomic ./...
|
||||
@go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
# Build the application
|
||||
build:
|
||||
@echo "Building gibidify..."
|
||||
@go build -ldflags="-s -w" -o gibidify .
|
||||
@echo "Build complete: ./gibidify"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -f gibidify gibidify-benchmark coverage.out coverage.html *.out
|
||||
@echo "Clean complete"
|
||||
|
||||
# CI-specific targets
|
||||
.PHONY: ci-lint ci-test
|
||||
|
||||
ci-lint:
|
||||
@revive -config revive.toml -formatter friendly -set_exit_status ./...
|
||||
|
||||
ci-test:
|
||||
@go test -race -coverprofile=coverage.out -json ./... > test-results.json
|
||||
|
||||
# Build benchmark binary
|
||||
build-benchmark:
|
||||
@echo "Building gibidify-benchmark..."
|
||||
@go build -ldflags="-s -w" -o gibidify-benchmark ./cmd/benchmark
|
||||
@echo "Build complete: ./gibidify-benchmark"
|
||||
|
||||
# Run custom benchmark binary
|
||||
benchmark: build-benchmark
|
||||
@echo "Running custom benchmarks..."
|
||||
@./gibidify-benchmark -type=all
|
||||
|
||||
# Run all Go test benchmarks
|
||||
benchmark-go:
|
||||
@echo "Running all Go test benchmarks..."
|
||||
@go test -bench=. -benchtime=100ms -run=^$$ ./...
|
||||
|
||||
# Run Go test benchmarks for specific packages
|
||||
benchmark-go-cli:
|
||||
@echo "Running CLI benchmarks..."
|
||||
@go test -bench=. -benchtime=100ms -run=^$$ ./cli/...
|
||||
|
||||
benchmark-go-fileproc:
|
||||
@echo "Running fileproc benchmarks..."
|
||||
@go test -bench=. -benchtime=100ms -run=^$$ ./fileproc/...
|
||||
|
||||
benchmark-go-metrics:
|
||||
@echo "Running metrics benchmarks..."
|
||||
@go test -bench=. -benchtime=100ms -run=^$$ ./metrics/...
|
||||
|
||||
benchmark-go-shared:
|
||||
@echo "Running shared benchmarks..."
|
||||
@go test -bench=. -benchtime=100ms -run=^$$ ./shared/...
|
||||
|
||||
# Run all benchmarks (custom + Go test)
|
||||
benchmark-all: benchmark benchmark-go
|
||||
|
||||
# Run specific benchmark types
|
||||
benchmark-collection: build-benchmark
|
||||
@echo "Running file collection benchmarks..."
|
||||
@./gibidify-benchmark -type=collection
|
||||
|
||||
benchmark-processing: build-benchmark
|
||||
@echo "Running file processing benchmarks..."
|
||||
@./gibidify-benchmark -type=processing
|
||||
|
||||
benchmark-concurrency: build-benchmark
|
||||
@echo "Running concurrency benchmarks..."
|
||||
@./gibidify-benchmark -type=concurrency
|
||||
|
||||
benchmark-format: build-benchmark
|
||||
@echo "Running format benchmarks..."
|
||||
@./gibidify-benchmark -type=format
|
||||
|
||||
# Security targets
|
||||
security:
|
||||
@echo "Running comprehensive security scan..."
|
||||
@./scripts/security-scan.sh
|
||||
|
||||
security-full: install-tools
|
||||
@echo "Running full security analysis..."
|
||||
@./scripts/security-scan.sh
|
||||
@echo "Running additional security checks..."
|
||||
@gosec -fmt=json -out=security-report.json ./...
|
||||
@staticcheck -checks=all ./...
|
||||
|
||||
vuln-check:
|
||||
@echo "Checking for dependency vulnerabilities..."
|
||||
@go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
@govulncheck ./...
|
||||
|
||||
# Update dependencies
|
||||
update-deps:
|
||||
@echo "Updating Go dependencies..."
|
||||
@./scripts/update-deps.sh
|
||||
102
README.md
102
README.md
@@ -7,11 +7,18 @@ file sections with separators, and a suffix.
|
||||
|
||||
## Features
|
||||
|
||||
- Recursive scanning of a source directory.
|
||||
- File filtering based on size, glob patterns, and .gitignore rules.
|
||||
- Modular, concurrent file processing with progress bar feedback.
|
||||
- Configurable logging and configuration via Viper.
|
||||
- Cross-platform build with Docker packaging support.
|
||||
- **Recursive directory scanning** with smart file filtering
|
||||
- **Configurable file type detection** - add/remove extensions and languages
|
||||
- **Multiple output formats** - markdown, JSON, YAML
|
||||
- **Memory-optimized processing** - streaming for large files, intelligent back-pressure
|
||||
- **Concurrent processing** with configurable worker pools
|
||||
- **Comprehensive configuration** via YAML with validation
|
||||
- **Production-ready** with structured error handling and benchmarking
|
||||
- **Modular architecture** - clean, focused codebase (92 files, ~21.5K lines) with ~63ns registry lookups
|
||||
- **Enhanced CLI experience** - progress bars, colored output, helpful error messages
|
||||
- **Cross-platform** with Docker support
|
||||
- **Advanced template system** - 4 built-in templates (default, minimal, detailed, compact) with custom template support, variable substitution, and YAML-based configuration
|
||||
- **Comprehensive metrics and profiling** - real-time processing statistics, performance analysis, memory usage tracking, and automated recommendations
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -26,9 +33,31 @@ go build -o gibidify .
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./gibidify -source <source_directory> -destination <output_file> [--prefix="..."] [--suffix="..."]
|
||||
./gibidify \
|
||||
-source <source_directory> \
|
||||
-destination <output_file> \
|
||||
-format markdown|json|yaml \
|
||||
-concurrency <num_workers> \
|
||||
--prefix="..." \
|
||||
--suffix="..." \
|
||||
--no-colors \
|
||||
--no-progress \
|
||||
--verbose \
|
||||
--log-level debug
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `-source`: directory to scan.
|
||||
- `-destination`: output file path (optional; defaults to `<source>.<format>`).
|
||||
- `-format`: output format (`markdown`, `json`, or `yaml`).
|
||||
- `-concurrency`: number of concurrent workers.
|
||||
- `--prefix` / `--suffix`: optional text blocks.
|
||||
- `--no-colors`: disable colored terminal output.
|
||||
- `--no-progress`: disable progress bars.
|
||||
- `--verbose`: enable verbose output and detailed logging.
|
||||
- `--log-level`: set log level (default: warn; accepted values: debug, info, warn, error).
|
||||
|
||||
## Docker
|
||||
|
||||
A Docker image can be built using the provided Dockerfile:
|
||||
@@ -69,11 +98,66 @@ ignoreDirectories:
|
||||
- dist
|
||||
- build
|
||||
- target
|
||||
- bower_components
|
||||
- cache
|
||||
- tmp
|
||||
|
||||
# FileType customization
|
||||
fileTypes:
|
||||
enabled: true
|
||||
# Add custom file extensions
|
||||
customImageExtensions:
|
||||
- .webp
|
||||
- .avif
|
||||
customBinaryExtensions:
|
||||
- .custom
|
||||
customLanguages:
|
||||
.zig: zig
|
||||
.odin: odin
|
||||
.v: vlang
|
||||
# Disable default extensions
|
||||
disabledImageExtensions:
|
||||
- .bmp
|
||||
disabledBinaryExtensions:
|
||||
- .exe
|
||||
disabledLanguageExtensions:
|
||||
- .bat
|
||||
|
||||
# Memory optimization (back-pressure management)
|
||||
backpressure:
|
||||
enabled: true
|
||||
maxPendingFiles: 1000 # Max files in file channel buffer
|
||||
maxPendingWrites: 100 # Max writes in write channel buffer
|
||||
maxMemoryUsage: 104857600 # 100MB max memory usage
|
||||
memoryCheckInterval: 1000 # Check memory every 1000 files
|
||||
|
||||
# Output and template customization
|
||||
output:
|
||||
# Template selection: default, minimal, detailed, compact, or custom
|
||||
# Templates control output structure and formatting
|
||||
template: "default"
|
||||
# Metadata options
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeTimestamp: true
|
||||
includeFileCount: true
|
||||
includeSourcePath: true
|
||||
includeMetrics: true
|
||||
# Markdown-specific options
|
||||
markdown:
|
||||
useCodeBlocks: true
|
||||
includeLanguage: true
|
||||
headerLevel: 2
|
||||
tableOfContents: false
|
||||
useCollapsible: false
|
||||
syntaxHighlighting: true
|
||||
lineNumbers: false
|
||||
# Custom template variables
|
||||
variables:
|
||||
project_name: "My Project"
|
||||
author: "Developer Name"
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
See `config.example.yaml` for a comprehensive configuration example.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [the MIT License](LICENSE).
|
||||
|
||||
130
TODO.md
Normal file
130
TODO.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# TODO: gibidify
|
||||
|
||||
Prioritized improvements by impact/effort.
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
**Core**: Config validation, structured errors, benchmarking, linting (revive: 0 issues) ✅
|
||||
**Architecture**: Modularization (92 files, ~21.5K lines), CLI (progress/colors), security (path validation, resource limits, scanning) ✅
|
||||
|
||||
## 🚀 Critical Priorities
|
||||
|
||||
### Testing Coverage (URGENT)
|
||||
- [x] **CLI module testing** (0% → 83.8%) - COMPLETED ✅
|
||||
- [x] cli/flags_test.go - Flag parsing and validation ✅
|
||||
- [x] cli/errors_test.go - Error formatting and structured errors ✅
|
||||
- [x] cli/ui_test.go - UI components, colors, progress bars ✅
|
||||
- [x] cli/processor_test.go - Processing workflow integration ✅
|
||||
- [x] **Utils module testing** (7.4% → 90.0%) - COMPLETED ✅
|
||||
- [x] utils/writers_test.go - Writer functions (98% complete, minor test fixes needed) ✅
|
||||
- [x] Enhanced utils/paths_test.go - Security and edge cases ✅
|
||||
- [x] Enhanced utils/errors_test.go - StructuredError system ✅
|
||||
- [x] **Testutil module testing** (45.1% → 73.7%) - COMPLETED ✅
|
||||
- [x] testutil/utility_test.go - GetBaseName function comprehensive tests ✅
|
||||
- [x] testutil/directory_structure_test.go - CreateTestDirectoryStructure and SetupTempDirWithStructure ✅
|
||||
- [x] testutil/assertions_test.go - All AssertError functions comprehensive coverage ✅
|
||||
- [x] testutil/error_scenarios_test.go - Edge cases and performance benchmarks ✅
|
||||
- [x] **Main module testing** (41% → 50.0%) - COMPLETED ✅
|
||||
- [x] **Fileproc module improvement** (66% → 74.5%) - COMPLETED ✅
|
||||
|
||||
### ✅ Metrics & Profiling - COMPLETED
|
||||
- [x] **Comprehensive metrics collection system** with processing statistics ✅
|
||||
- [x] File processing metrics (processed, skipped, errors) ✅
|
||||
- [x] Size metrics (total, average, largest, smallest file sizes) ✅
|
||||
- [x] Performance metrics (files/sec, bytes/sec, processing time) ✅
|
||||
- [x] Memory and resource tracking (peak memory, current memory, goroutine count) ✅
|
||||
- [x] Format-specific metrics and error breakdown ✅
|
||||
- [x] Phase timing (collection, processing, writing, finalize) ✅
|
||||
- [x] Concurrency tracking and recommendations ✅
|
||||
- [x] **Performance measurements and reporting** ✅
|
||||
- [x] Real-time progress reporting in CLI ✅
|
||||
- [x] Verbose mode with detailed statistics ✅
|
||||
- [x] Final comprehensive profiling reports ✅
|
||||
- [x] Performance recommendations based on metrics ✅
|
||||
- [x] **Structured logging integration** with centralized logging service ✅
|
||||
- [x] Configurable log levels (debug, info, warn, error) ✅
|
||||
- [x] Context-aware logging with structured data ✅
|
||||
- [x] Metrics data integration in log output ✅
|
||||
|
||||
### ✅ Output Customization - COMPLETED
|
||||
- [x] **Template system for output formatting** ✅
|
||||
- [x] Builtin templates: default, minimal, detailed, compact ✅
|
||||
- [x] Custom template support with variables ✅
|
||||
- [x] Template functions for formatting (formatSize, basename, etc.) ✅
|
||||
- [x] Header/footer and file header/footer customization ✅
|
||||
- [x] **Configurable markdown options** ✅
|
||||
- [x] Code block controls (syntax highlighting, line numbers) ✅
|
||||
- [x] Header levels and table of contents ✅
|
||||
- [x] Collapsible sections for space efficiency ✅
|
||||
- [x] Line length limits and long file folding ✅
|
||||
- [x] Custom CSS support ✅
|
||||
- [x] **Metadata integration in outputs** ✅
|
||||
- [x] Configurable metadata inclusion (stats, timestamp, file counts) ✅
|
||||
- [x] Processing metrics in output (performance, memory usage) ✅
|
||||
- [x] File type breakdown and error summaries ✅
|
||||
- [x] Source path and processing time information ✅
|
||||
- [x] **Enhanced configuration system** ✅
|
||||
- [x] Template selection and customization options ✅
|
||||
- [x] Metadata control flags ✅
|
||||
- [x] Markdown formatting preferences ✅
|
||||
- [x] Custom template variables support ✅
|
||||
|
||||
### Documentation
|
||||
- [ ] API docs, user guides
|
||||
|
||||
## Guidelines
|
||||
|
||||
**Before**: `make lint-fix && make lint` (0 issues), >80% coverage
|
||||
**Priorities**: Testing → Security → UX → Extensions
|
||||
|
||||
## Status (2025-08-23 - Phase 3 Feature Implementation Complete)
|
||||
|
||||
**Health: 10/10** - Advanced metrics & profiling system and comprehensive output customization implemented
|
||||
|
||||
**Stats**: 92 files (~21.5K lines), 77.9% overall coverage achieved
|
||||
- CLI: 83.8% ✅, Utils: 90.0% ✅, Config: 77.0% ✅, Testutil: 73.7% ✅, Fileproc: 74.5% ✅, Main: 50.0% ✅, Metrics: 96.0% ✅, Templates: 87.3% ✅, Benchmark: 64.7% ✅
|
||||
|
||||
**Completed Today**:
|
||||
- ✅ **Phase 1**: Consolidated duplicate code patterns
|
||||
- Writer closeReader → utils.SafeCloseReader
|
||||
- Custom yamlQuoteString → utils.EscapeForYAML
|
||||
- Streaming patterns → utils.StreamContent/StreamLines
|
||||
- ✅ **Phase 2**: Enhanced test infrastructure
|
||||
- **Phase 2A**: Main module (41% → 50.0%) - Complete integration testing
|
||||
- **Phase 2B**: Fileproc module (66% → 74.5%) - Streaming and backpressure testing
|
||||
- **Phase 2C**: Testutil module (45.1% → 73.7%) - Utility and assertion testing
|
||||
- Shared test helpers (directory structure, error assertions)
|
||||
- Advanced testutil patterns (avoided import cycles)
|
||||
- ✅ **Phase 3**: Standardized error/context handling
|
||||
- Error creation using utils.WrapError family
|
||||
- Centralized context cancellation patterns
|
||||
- ✅ **Phase 4**: Documentation updates
|
||||
|
||||
**Impact**: Eliminated code duplication, enhanced maintainability, achieved comprehensive test coverage across all major modules
|
||||
|
||||
**Completed This Session**:
|
||||
- ✅ **Phase 3A**: Advanced Metrics & Profiling System
|
||||
- Comprehensive processing statistics collection (files, sizes, performance)
|
||||
- Real-time progress reporting with detailed metrics
|
||||
- Phase timing tracking (collection, processing, writing, finalize)
|
||||
- Memory and resource usage monitoring
|
||||
- Format-specific metrics and error breakdown
|
||||
- Performance recommendations engine
|
||||
- Structured logging integration
|
||||
- ✅ **Phase 3B**: Output Customization Features
|
||||
- Template system with 4 builtin templates (default, minimal, detailed, compact)
|
||||
- Custom template support with variable substitution
|
||||
- Configurable markdown options (code blocks, TOC, collapsible sections)
|
||||
- Metadata integration with selective inclusion controls
|
||||
- Enhanced configuration system for all customization options
|
||||
- ✅ **Phase 3C**: Comprehensive Testing & Integration
|
||||
- Full test coverage for metrics and templates packages
|
||||
- Integration with existing CLI processor workflow
|
||||
- Deadlock-free concurrent metrics collection
|
||||
- Configuration system extensions
|
||||
|
||||
**Impact**: Added powerful analytics and customization capabilities while maintaining high code quality and test coverage
|
||||
|
||||
**Next Session**:
|
||||
- Phase 4: Enhanced documentation and user guides
|
||||
- Optional: Advanced features (watch mode, incremental processing, etc.)
|
||||
535
benchmark/benchmark.go
Normal file
535
benchmark/benchmark.go
Normal file
@@ -0,0 +1,535 @@
|
||||
// Package benchmark provides benchmarking infrastructure for gibidify.
|
||||
package benchmark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Result represents the results of a benchmark run.
|
||||
type Result struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
FilesProcessed int
|
||||
BytesProcessed int64
|
||||
FilesPerSecond float64
|
||||
BytesPerSecond float64
|
||||
MemoryUsage MemoryStats
|
||||
CPUUsage CPUStats
|
||||
}
|
||||
|
||||
// MemoryStats represents memory usage statistics.
|
||||
type MemoryStats struct {
|
||||
AllocMB float64
|
||||
SysMB float64
|
||||
NumGC uint32
|
||||
PauseTotalNs uint64
|
||||
}
|
||||
|
||||
// CPUStats represents CPU usage statistics.
|
||||
type CPUStats struct {
|
||||
UserTime time.Duration
|
||||
SystemTime time.Duration
|
||||
Goroutines int
|
||||
}
|
||||
|
||||
// Suite represents a collection of benchmarks.
|
||||
type Suite struct {
|
||||
Name string
|
||||
Results []Result
|
||||
}
|
||||
|
||||
// buildBenchmarkResult constructs a Result with all metrics calculated.
|
||||
// This eliminates code duplication across benchmark functions.
|
||||
func buildBenchmarkResult(
|
||||
name string,
|
||||
files []string,
|
||||
totalBytes int64,
|
||||
duration time.Duration,
|
||||
memBefore, memAfter runtime.MemStats,
|
||||
) *Result {
|
||||
result := &Result{
|
||||
Name: name,
|
||||
Duration: duration,
|
||||
FilesProcessed: len(files),
|
||||
BytesProcessed: totalBytes,
|
||||
}
|
||||
|
||||
// Calculate rates with zero-division guard
|
||||
secs := duration.Seconds()
|
||||
if secs == 0 {
|
||||
result.FilesPerSecond = 0
|
||||
result.BytesPerSecond = 0
|
||||
} else {
|
||||
result.FilesPerSecond = float64(len(files)) / secs
|
||||
result.BytesPerSecond = float64(totalBytes) / secs
|
||||
}
|
||||
|
||||
result.MemoryUsage = MemoryStats{
|
||||
AllocMB: shared.SafeMemoryDiffMB(memAfter.Alloc, memBefore.Alloc),
|
||||
SysMB: shared.SafeMemoryDiffMB(memAfter.Sys, memBefore.Sys),
|
||||
NumGC: memAfter.NumGC - memBefore.NumGC,
|
||||
PauseTotalNs: memAfter.PauseTotalNs - memBefore.PauseTotalNs,
|
||||
}
|
||||
|
||||
result.CPUUsage = CPUStats{
|
||||
Goroutines: runtime.NumGoroutine(),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FileCollectionBenchmark benchmarks file collection operations.
|
||||
func FileCollectionBenchmark(sourceDir string, numFiles int) (*Result, error) {
|
||||
// Load configuration to ensure proper file filtering
|
||||
config.LoadConfig()
|
||||
|
||||
// Create temporary directory with test files if no source is provided
|
||||
var cleanup func()
|
||||
if sourceDir == "" {
|
||||
tempDir, cleanupFunc, err := createBenchmarkFiles(numFiles)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
shared.BenchmarkMsgFailedToCreateFiles,
|
||||
)
|
||||
}
|
||||
cleanup = cleanupFunc
|
||||
//nolint:errcheck // Benchmark output, errors don't affect results
|
||||
defer cleanup()
|
||||
sourceDir = tempDir
|
||||
}
|
||||
|
||||
// Measure memory before
|
||||
var memBefore runtime.MemStats
|
||||
runtime.ReadMemStats(&memBefore)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Run the file collection benchmark
|
||||
files, err := fileproc.CollectFiles(sourceDir)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgCollectionFailed,
|
||||
)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Measure memory after
|
||||
var memAfter runtime.MemStats
|
||||
runtime.ReadMemStats(&memAfter)
|
||||
|
||||
// Calculate total bytes processed
|
||||
var totalBytes int64
|
||||
for _, file := range files {
|
||||
if info, err := os.Stat(file); err == nil {
|
||||
totalBytes += info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
result := buildBenchmarkResult("FileCollection", files, totalBytes, duration, memBefore, memAfter)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FileProcessingBenchmark benchmarks full file processing pipeline.
|
||||
func FileProcessingBenchmark(sourceDir string, format string, concurrency int) (*Result, error) {
|
||||
// Load configuration to ensure proper file filtering
|
||||
config.LoadConfig()
|
||||
|
||||
var cleanup func()
|
||||
if sourceDir == "" {
|
||||
// Create temporary directory with test files
|
||||
tempDir, cleanupFunc, err := createBenchmarkFiles(shared.BenchmarkDefaultFileCount)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
shared.BenchmarkMsgFailedToCreateFiles,
|
||||
)
|
||||
}
|
||||
cleanup = cleanupFunc
|
||||
//nolint:errcheck // Benchmark output, errors don't affect results
|
||||
defer cleanup()
|
||||
sourceDir = tempDir
|
||||
}
|
||||
|
||||
// Create temporary output file
|
||||
outputFile, err := os.CreateTemp("", "benchmark_output_*."+format)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOFileCreate,
|
||||
"failed to create benchmark output file",
|
||||
)
|
||||
}
|
||||
defer func() {
|
||||
if err := outputFile.Close(); err != nil {
|
||||
//nolint:errcheck // Warning message in defer, failure doesn't affect benchmark
|
||||
_, _ = fmt.Printf("Warning: failed to close benchmark output file: %v\n", err)
|
||||
}
|
||||
if err := os.Remove(outputFile.Name()); err != nil {
|
||||
//nolint:errcheck // Warning message in defer, failure doesn't affect benchmark
|
||||
_, _ = fmt.Printf("Warning: failed to remove benchmark output file: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Measure memory before
|
||||
var memBefore runtime.MemStats
|
||||
runtime.ReadMemStats(&memBefore)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Run the full processing pipeline
|
||||
files, err := fileproc.CollectFiles(sourceDir)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgCollectionFailed,
|
||||
)
|
||||
}
|
||||
|
||||
// Process files with concurrency
|
||||
err = runProcessingPipeline(context.Background(), files, outputFile, format, concurrency, sourceDir)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingFileRead,
|
||||
"benchmark processing pipeline failed",
|
||||
)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Measure memory after
|
||||
var memAfter runtime.MemStats
|
||||
runtime.ReadMemStats(&memAfter)
|
||||
|
||||
// Calculate total bytes processed
|
||||
var totalBytes int64
|
||||
for _, file := range files {
|
||||
if info, err := os.Stat(file); err == nil {
|
||||
totalBytes += info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
benchmarkName := fmt.Sprintf("FileProcessing_%s_c%d", format, concurrency)
|
||||
result := buildBenchmarkResult(benchmarkName, files, totalBytes, duration, memBefore, memAfter)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ConcurrencyBenchmark benchmarks different concurrency levels.
|
||||
func ConcurrencyBenchmark(sourceDir string, format string, concurrencyLevels []int) (*Suite, error) {
|
||||
suite := &Suite{
|
||||
Name: "ConcurrencyBenchmark",
|
||||
Results: make([]Result, 0, len(concurrencyLevels)),
|
||||
}
|
||||
|
||||
for _, concurrency := range concurrencyLevels {
|
||||
result, err := FileProcessingBenchmark(sourceDir, format, concurrency)
|
||||
if err != nil {
|
||||
return nil, shared.WrapErrorf(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
"concurrency benchmark failed for level %d",
|
||||
concurrency,
|
||||
)
|
||||
}
|
||||
suite.Results = append(suite.Results, *result)
|
||||
}
|
||||
|
||||
return suite, nil
|
||||
}
|
||||
|
||||
// FormatBenchmark benchmarks different output formats.
|
||||
func FormatBenchmark(sourceDir string, formats []string) (*Suite, error) {
|
||||
suite := &Suite{
|
||||
Name: "FormatBenchmark",
|
||||
Results: make([]Result, 0, len(formats)),
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
result, err := FileProcessingBenchmark(sourceDir, format, runtime.NumCPU())
|
||||
if err != nil {
|
||||
return nil, shared.WrapErrorf(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
"format benchmark failed for format %s",
|
||||
format,
|
||||
)
|
||||
}
|
||||
suite.Results = append(suite.Results, *result)
|
||||
}
|
||||
|
||||
return suite, nil
|
||||
}
|
||||
|
||||
// createBenchmarkFiles creates temporary files for benchmarking.
|
||||
func createBenchmarkFiles(numFiles int) (string, func(), error) {
|
||||
tempDir, err := os.MkdirTemp("", "gibidify_benchmark_*")
|
||||
if err != nil {
|
||||
return "", nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
"failed to create temp directory",
|
||||
)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
if err := os.RemoveAll(tempDir); err != nil {
|
||||
//nolint:errcheck // Warning message in cleanup, failure doesn't affect benchmark
|
||||
_, _ = fmt.Printf("Warning: failed to remove benchmark temp directory: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create various file types
|
||||
fileTypes := []struct {
|
||||
ext string
|
||||
content string
|
||||
}{
|
||||
{".go", "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}"},
|
||||
{".js", "console.log('Hello, World!');"},
|
||||
{".py", "print('Hello, World!')"},
|
||||
{
|
||||
".java",
|
||||
"public class Hello {\n\tpublic static void main(String[] args) {\n\t" +
|
||||
"\tSystem.out.println(\"Hello, World!\");\n\t}\n}",
|
||||
},
|
||||
{
|
||||
".cpp",
|
||||
"#include <iostream>\n\n" +
|
||||
"int main() {\n\tstd::cout << \"Hello, World!\" << std::endl;\n\treturn 0;\n}",
|
||||
},
|
||||
{".rs", "fn main() {\n\tprintln!(\"Hello, World!\");\n}"},
|
||||
{".rb", "puts 'Hello, World!'"},
|
||||
{".php", "<?php\necho 'Hello, World!';\n?>"},
|
||||
{".sh", "#!/bin/bash\necho 'Hello, World!'"},
|
||||
{".md", "# Hello, World!\n\nThis is a markdown file."},
|
||||
}
|
||||
|
||||
for i := 0; i < numFiles; i++ {
|
||||
fileType := fileTypes[i%len(fileTypes)]
|
||||
filename := fmt.Sprintf("file_%d%s", i, fileType.ext)
|
||||
|
||||
// Create subdirectories for some files
|
||||
if i%10 == 0 {
|
||||
subdir := filepath.Join(tempDir, fmt.Sprintf("subdir_%d", i/10))
|
||||
if err := os.MkdirAll(subdir, 0o750); err != nil {
|
||||
cleanup()
|
||||
|
||||
return "", nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
"failed to create subdirectory",
|
||||
)
|
||||
}
|
||||
filename = filepath.Join(subdir, filename)
|
||||
} else {
|
||||
filename = filepath.Join(tempDir, filename)
|
||||
}
|
||||
|
||||
// Create file with repeated content to make it larger
|
||||
content := ""
|
||||
for j := 0; j < 10; j++ {
|
||||
content += fmt.Sprintf("// Line %d\n%s\n", j, fileType.content)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, []byte(content), 0o600); err != nil {
|
||||
cleanup()
|
||||
|
||||
return "", nil, shared.WrapError(
|
||||
err, shared.ErrorTypeIO, shared.CodeIOFileWrite, "failed to write benchmark file",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return tempDir, cleanup, nil
|
||||
}
|
||||
|
||||
// runProcessingPipeline runs the processing pipeline similar to main.go.
|
||||
func runProcessingPipeline(
|
||||
ctx context.Context,
|
||||
files []string,
|
||||
outputFile *os.File,
|
||||
format string,
|
||||
concurrency int,
|
||||
sourceDir string,
|
||||
) error {
|
||||
// Guard against invalid concurrency to prevent deadlocks
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
|
||||
fileCh := make(chan string, concurrency)
|
||||
writeCh := make(chan fileproc.WriteRequest, concurrency)
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Start writer
|
||||
go fileproc.StartWriter(outputFile, writeCh, writerDone, format, "", "")
|
||||
|
||||
// Get absolute path once
|
||||
absRoot, err := shared.AbsolutePath(sourceDir)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSPathResolution,
|
||||
"failed to get absolute path for source directory",
|
||||
)
|
||||
}
|
||||
|
||||
// Start workers with proper synchronization
|
||||
var workersDone sync.WaitGroup
|
||||
for i := 0; i < concurrency; i++ {
|
||||
workersDone.Add(1)
|
||||
go func() {
|
||||
defer workersDone.Done()
|
||||
for filePath := range fileCh {
|
||||
fileproc.ProcessFile(filePath, writeCh, absRoot)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Send files to workers
|
||||
for _, file := range files {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
close(fileCh)
|
||||
workersDone.Wait() // Wait for workers to finish
|
||||
close(writeCh)
|
||||
<-writerDone
|
||||
|
||||
return fmt.Errorf("context canceled: %w", ctx.Err())
|
||||
case fileCh <- file:
|
||||
}
|
||||
}
|
||||
|
||||
// Close file channel and wait for workers to finish
|
||||
close(fileCh)
|
||||
workersDone.Wait()
|
||||
|
||||
// Now it's safe to close the write channel
|
||||
close(writeCh)
|
||||
<-writerDone
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintResult prints a formatted benchmark result.
|
||||
func PrintResult(result *Result) {
|
||||
printBenchmarkLine := func(format string, args ...any) {
|
||||
if _, err := fmt.Printf(format, args...); err != nil {
|
||||
// Stdout write errors are rare (broken pipe, etc.) - log but continue
|
||||
shared.LogError("failed to write benchmark output", err)
|
||||
}
|
||||
}
|
||||
|
||||
printBenchmarkLine(shared.BenchmarkFmtSectionHeader, result.Name)
|
||||
printBenchmarkLine("Duration: %v\n", result.Duration)
|
||||
printBenchmarkLine("Files Processed: %d\n", result.FilesProcessed)
|
||||
printBenchmarkLine("Bytes Processed: %d (%.2f MB)\n", result.BytesProcessed,
|
||||
float64(result.BytesProcessed)/float64(shared.BytesPerMB))
|
||||
printBenchmarkLine("Files/sec: %.2f\n", result.FilesPerSecond)
|
||||
printBenchmarkLine("Bytes/sec: %.2f MB/sec\n", result.BytesPerSecond/float64(shared.BytesPerMB))
|
||||
printBenchmarkLine(
|
||||
"Memory Usage: +%.2f MB (Sys: +%.2f MB)\n",
|
||||
result.MemoryUsage.AllocMB,
|
||||
result.MemoryUsage.SysMB,
|
||||
)
|
||||
//nolint:errcheck // Overflow unlikely for pause duration, result output only
|
||||
pauseDuration, _ := shared.SafeUint64ToInt64(result.MemoryUsage.PauseTotalNs)
|
||||
printBenchmarkLine("GC Runs: %d (Pause: %v)\n", result.MemoryUsage.NumGC, time.Duration(pauseDuration))
|
||||
printBenchmarkLine("Goroutines: %d\n", result.CPUUsage.Goroutines)
|
||||
printBenchmarkLine("\n")
|
||||
}
|
||||
|
||||
// PrintSuite prints all results in a benchmark suite.
|
||||
func PrintSuite(suite *Suite) {
|
||||
if _, err := fmt.Printf(shared.BenchmarkFmtSectionHeader, suite.Name); err != nil {
|
||||
shared.LogError("failed to write benchmark suite header", err)
|
||||
}
|
||||
// Iterate by index to avoid taking address of range variable
|
||||
for i := range suite.Results {
|
||||
PrintResult(&suite.Results[i])
|
||||
}
|
||||
}
|
||||
|
||||
// RunAllBenchmarks runs a comprehensive benchmark suite.
|
||||
func RunAllBenchmarks(sourceDir string) error {
|
||||
printBenchmark := func(msg string) {
|
||||
if _, err := fmt.Println(msg); err != nil {
|
||||
shared.LogError("failed to write benchmark message", err)
|
||||
}
|
||||
}
|
||||
|
||||
printBenchmark("Running gibidify benchmark suite...")
|
||||
|
||||
// Load configuration
|
||||
config.LoadConfig()
|
||||
|
||||
// File collection benchmark
|
||||
printBenchmark(shared.BenchmarkMsgRunningCollection)
|
||||
result, err := FileCollectionBenchmark(sourceDir, shared.BenchmarkDefaultFileCount)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgFileCollectionFailed,
|
||||
)
|
||||
}
|
||||
PrintResult(result)
|
||||
|
||||
// Format benchmarks
|
||||
printBenchmark("Running format benchmarks...")
|
||||
formats := []string{shared.FormatJSON, shared.FormatYAML, shared.FormatMarkdown}
|
||||
formatSuite, err := FormatBenchmark(sourceDir, formats)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgFormatFailed,
|
||||
)
|
||||
}
|
||||
PrintSuite(formatSuite)
|
||||
|
||||
// Concurrency benchmarks
|
||||
printBenchmark("Running concurrency benchmarks...")
|
||||
concurrencyLevels := []int{1, 2, 4, 8, runtime.NumCPU()}
|
||||
concurrencySuite, err := ConcurrencyBenchmark(sourceDir, shared.FormatJSON, concurrencyLevels)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgConcurrencyFailed,
|
||||
)
|
||||
}
|
||||
PrintSuite(concurrencySuite)
|
||||
|
||||
return nil
|
||||
}
|
||||
517
benchmark/benchmark_test.go
Normal file
517
benchmark/benchmark_test.go
Normal file
@@ -0,0 +1,517 @@
|
||||
package benchmark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// capturedOutput captures stdout output from a function call.
|
||||
func capturedOutput(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
original := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreatePipe, err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer func() { os.Stdout = original }()
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Logf(shared.TestMsgFailedToClose, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// verifyOutputContains checks if output contains all expected strings.
|
||||
func verifyOutputContains(t *testing.T, testName, output string, expected []string) {
|
||||
t.Helper()
|
||||
for _, check := range expected {
|
||||
if !strings.Contains(output, check) {
|
||||
t.Errorf("Test %s: output missing expected content: %q\nFull output:\n%s", testName, check, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCollectionBenchmark tests the file collection benchmark.
|
||||
func TestFileCollectionBenchmark(t *testing.T) {
|
||||
result, err := FileCollectionBenchmark("", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FileCollectionBenchmark failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Name != "FileCollection" {
|
||||
t.Errorf("Expected name 'FileCollection', got %s", result.Name)
|
||||
}
|
||||
|
||||
// Debug information
|
||||
t.Logf("Files processed: %d", result.FilesProcessed)
|
||||
t.Logf("Duration: %v", result.Duration)
|
||||
t.Logf("Bytes processed: %d", result.BytesProcessed)
|
||||
|
||||
if result.FilesProcessed <= 0 {
|
||||
t.Errorf(shared.TestFmtExpectedFilesProcessed, result.FilesProcessed)
|
||||
}
|
||||
|
||||
if result.Duration <= 0 {
|
||||
t.Errorf("Expected duration > 0, got %v", result.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileProcessingBenchmark tests the file processing benchmark.
|
||||
func TestFileProcessingBenchmark(t *testing.T) {
|
||||
result, err := FileProcessingBenchmark("", "json", 2)
|
||||
if err != nil {
|
||||
t.Fatalf("FileProcessingBenchmark failed: %v", err)
|
||||
}
|
||||
|
||||
if result.FilesProcessed <= 0 {
|
||||
t.Errorf(shared.TestFmtExpectedFilesProcessed, result.FilesProcessed)
|
||||
}
|
||||
|
||||
if result.Duration <= 0 {
|
||||
t.Errorf("Expected duration > 0, got %v", result.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrencyBenchmark tests the concurrency benchmark.
|
||||
func TestConcurrencyBenchmark(t *testing.T) {
|
||||
concurrencyLevels := []int{1, 2}
|
||||
suite, err := ConcurrencyBenchmark("", "json", concurrencyLevels)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcurrencyBenchmark failed: %v", err)
|
||||
}
|
||||
|
||||
if suite.Name != "ConcurrencyBenchmark" {
|
||||
t.Errorf("Expected name 'ConcurrencyBenchmark', got %s", suite.Name)
|
||||
}
|
||||
|
||||
if len(suite.Results) != len(concurrencyLevels) {
|
||||
t.Errorf(shared.TestFmtExpectedResults, len(concurrencyLevels), len(suite.Results))
|
||||
}
|
||||
|
||||
for i, result := range suite.Results {
|
||||
if result.FilesProcessed <= 0 {
|
||||
t.Errorf("Result %d: "+shared.TestFmtExpectedFilesProcessed, i, result.FilesProcessed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatBenchmark tests the format benchmark.
|
||||
func TestFormatBenchmark(t *testing.T) {
|
||||
formats := []string{"json", "yaml"}
|
||||
suite, err := FormatBenchmark("", formats)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatBenchmark failed: %v", err)
|
||||
}
|
||||
|
||||
if suite.Name != "FormatBenchmark" {
|
||||
t.Errorf("Expected name 'FormatBenchmark', got %s", suite.Name)
|
||||
}
|
||||
|
||||
if len(suite.Results) != len(formats) {
|
||||
t.Errorf(shared.TestFmtExpectedResults, len(formats), len(suite.Results))
|
||||
}
|
||||
|
||||
for i, result := range suite.Results {
|
||||
if result.FilesProcessed <= 0 {
|
||||
t.Errorf("Result %d: "+shared.TestFmtExpectedFilesProcessed, i, result.FilesProcessed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateBenchmarkFiles tests the benchmark file creation.
|
||||
func TestCreateBenchmarkFiles(t *testing.T) {
|
||||
tempDir, cleanup, err := createBenchmarkFiles(5)
|
||||
if err != nil {
|
||||
t.Fatalf("createBenchmarkFiles failed: %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if tempDir == "" {
|
||||
t.Error("Expected non-empty temp directory")
|
||||
}
|
||||
|
||||
// Verify files were created
|
||||
// This is tested indirectly through the benchmark functions
|
||||
}
|
||||
|
||||
// BenchmarkFileCollection benchmarks the file collection process.
|
||||
func BenchmarkFileCollection(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, err := FileCollectionBenchmark("", 50)
|
||||
if err != nil {
|
||||
b.Fatalf("FileCollectionBenchmark failed: %v", err)
|
||||
}
|
||||
if result.FilesProcessed <= 0 {
|
||||
b.Errorf(shared.TestFmtExpectedFilesProcessed, result.FilesProcessed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFileProcessing benchmarks the file processing pipeline.
|
||||
func BenchmarkFileProcessing(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, err := FileProcessingBenchmark("", "json", runtime.NumCPU())
|
||||
if err != nil {
|
||||
b.Fatalf("FileProcessingBenchmark failed: %v", err)
|
||||
}
|
||||
if result.FilesProcessed <= 0 {
|
||||
b.Errorf(shared.TestFmtExpectedFilesProcessed, result.FilesProcessed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConcurrency benchmarks different concurrency levels.
|
||||
func BenchmarkConcurrency(b *testing.B) {
|
||||
concurrencyLevels := []int{1, 2, 4}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
suite, err := ConcurrencyBenchmark("", "json", concurrencyLevels)
|
||||
if err != nil {
|
||||
b.Fatalf("ConcurrencyBenchmark failed: %v", err)
|
||||
}
|
||||
if len(suite.Results) != len(concurrencyLevels) {
|
||||
b.Errorf(shared.TestFmtExpectedResults, len(concurrencyLevels), len(suite.Results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFormats benchmarks different output formats.
|
||||
func BenchmarkFormats(b *testing.B) {
|
||||
formats := []string{"json", "yaml", "markdown"}
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
suite, err := FormatBenchmark("", formats)
|
||||
if err != nil {
|
||||
b.Fatalf("FormatBenchmark failed: %v", err)
|
||||
}
|
||||
if len(suite.Results) != len(formats) {
|
||||
b.Errorf(shared.TestFmtExpectedResults, len(formats), len(suite.Results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintResult tests the PrintResult function.
|
||||
func TestPrintResult(t *testing.T) {
|
||||
// Create a test result
|
||||
result := &Result{
|
||||
Name: "Test Benchmark",
|
||||
Duration: 1 * time.Second,
|
||||
FilesProcessed: 100,
|
||||
BytesProcessed: 2048000, // ~2MB for easy calculation
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
original := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreatePipe, err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer func() { os.Stdout = original }()
|
||||
os.Stdout = w
|
||||
|
||||
// Call PrintResult
|
||||
PrintResult(result)
|
||||
|
||||
// Close writer and read captured output
|
||||
if err := w.Close(); err != nil {
|
||||
t.Logf(shared.TestMsgFailedToClose, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
// Verify expected content
|
||||
expectedContents := []string{
|
||||
"=== Test Benchmark ===",
|
||||
"Duration: 1s",
|
||||
"Files Processed: 100",
|
||||
"Bytes Processed: 2048000",
|
||||
"1.95 MB", // 2048000 / 1024 / 1024 ≈ 1.95
|
||||
}
|
||||
|
||||
for _, expected := range expectedContents {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("PrintResult output missing expected content: %q\nFull output:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSuite tests the PrintSuite function.
|
||||
func TestPrintSuite(t *testing.T) {
|
||||
// Create a test suite with multiple results
|
||||
suite := &Suite{
|
||||
Name: "Test Suite",
|
||||
Results: []Result{
|
||||
{
|
||||
Name: "Benchmark 1",
|
||||
Duration: 500 * time.Millisecond,
|
||||
FilesProcessed: 50,
|
||||
BytesProcessed: 1024000, // 1MB
|
||||
},
|
||||
{
|
||||
Name: "Benchmark 2",
|
||||
Duration: 750 * time.Millisecond,
|
||||
FilesProcessed: 75,
|
||||
BytesProcessed: 1536000, // 1.5MB
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Capture stdout
|
||||
original := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreatePipe, err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer func() { os.Stdout = original }()
|
||||
os.Stdout = w
|
||||
|
||||
// Call PrintSuite
|
||||
PrintSuite(suite)
|
||||
|
||||
// Close writer and read captured output
|
||||
if err := w.Close(); err != nil {
|
||||
t.Logf(shared.TestMsgFailedToClose, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToReadOutput, err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
// Verify expected content
|
||||
expectedContents := []string{
|
||||
"=== Test Suite ===",
|
||||
"=== Benchmark 1 ===",
|
||||
"Duration: 500ms",
|
||||
"Files Processed: 50",
|
||||
"=== Benchmark 2 ===",
|
||||
"Duration: 750ms",
|
||||
"Files Processed: 75",
|
||||
}
|
||||
|
||||
for _, expected := range expectedContents {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("PrintSuite output missing expected content: %q\nFull output:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify both results are printed
|
||||
benchmark1Count := strings.Count(output, "=== Benchmark 1 ===")
|
||||
benchmark2Count := strings.Count(output, "=== Benchmark 2 ===")
|
||||
|
||||
if benchmark1Count != 1 {
|
||||
t.Errorf("Expected exactly 1 occurrence of 'Benchmark 1', got %d", benchmark1Count)
|
||||
}
|
||||
if benchmark2Count != 1 {
|
||||
t.Errorf("Expected exactly 1 occurrence of 'Benchmark 2', got %d", benchmark2Count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintResultEdgeCases tests edge cases for PrintResult.
|
||||
func TestPrintResultEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *Result
|
||||
checks []string
|
||||
}{
|
||||
{
|
||||
name: "zero values",
|
||||
result: &Result{
|
||||
Name: "Zero Benchmark",
|
||||
Duration: 0,
|
||||
FilesProcessed: 0,
|
||||
BytesProcessed: 0,
|
||||
},
|
||||
checks: []string{
|
||||
"=== Zero Benchmark ===",
|
||||
"Duration: 0s",
|
||||
"Files Processed: 0",
|
||||
"Bytes Processed: 0",
|
||||
"0.00 MB",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "large values",
|
||||
result: &Result{
|
||||
Name: "Large Benchmark",
|
||||
Duration: 1 * time.Hour,
|
||||
FilesProcessed: 1000000,
|
||||
BytesProcessed: 1073741824, // 1GB
|
||||
},
|
||||
checks: []string{
|
||||
"=== Large Benchmark ===",
|
||||
"Duration: 1h0m0s",
|
||||
"Files Processed: 1000000",
|
||||
"Bytes Processed: 1073741824",
|
||||
"1024.00 MB",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
result: &Result{
|
||||
Name: "",
|
||||
Duration: 100 * time.Millisecond,
|
||||
FilesProcessed: 10,
|
||||
BytesProcessed: 1024,
|
||||
},
|
||||
checks: []string{
|
||||
"=== ===", // Empty name between === markers
|
||||
"Duration: 100ms",
|
||||
"Files Processed: 10",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.result
|
||||
output := capturedOutput(t, func() { PrintResult(result) })
|
||||
verifyOutputContains(t, tt.name, output, tt.checks)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSuiteEdgeCases tests edge cases for PrintSuite.
|
||||
func TestPrintSuiteEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
suite *Suite
|
||||
checks []string
|
||||
}{
|
||||
{
|
||||
name: "empty suite",
|
||||
suite: &Suite{
|
||||
Name: "Empty Suite",
|
||||
Results: []Result{},
|
||||
},
|
||||
checks: []string{
|
||||
"=== Empty Suite ===",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "suite with empty name",
|
||||
suite: &Suite{
|
||||
Name: "",
|
||||
Results: []Result{
|
||||
{
|
||||
Name: "Single Benchmark",
|
||||
Duration: 200 * time.Millisecond,
|
||||
FilesProcessed: 20,
|
||||
BytesProcessed: 2048,
|
||||
},
|
||||
},
|
||||
},
|
||||
checks: []string{
|
||||
"=== ===", // Empty name
|
||||
"=== Single Benchmark ===",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
suite := tt.suite
|
||||
output := capturedOutput(t, func() { PrintSuite(suite) })
|
||||
verifyOutputContains(t, tt.name, output, tt.checks)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunAllBenchmarks tests the RunAllBenchmarks function.
|
||||
func TestRunAllBenchmarks(t *testing.T) {
|
||||
// Create a temporary directory with some test files
|
||||
srcDir := t.TempDir()
|
||||
|
||||
// Create a few test files
|
||||
testFiles := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{shared.TestFileMainGo, "package main\nfunc main() {}"},
|
||||
{shared.TestFile2Name, "Hello World"},
|
||||
{shared.TestFile3Name, "# Test Markdown"},
|
||||
}
|
||||
|
||||
for _, file := range testFiles {
|
||||
filePath := filepath.Join(srcDir, file.name)
|
||||
err := os.WriteFile(filePath, []byte(file.content), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", file.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture stdout to verify output
|
||||
original := os.Stdout
|
||||
r, w, pipeErr := os.Pipe()
|
||||
if pipeErr != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreatePipe, pipeErr)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Logf("Failed to close pipe reader: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() { os.Stdout = original }()
|
||||
os.Stdout = w
|
||||
|
||||
// Call RunAllBenchmarks
|
||||
err := RunAllBenchmarks(srcDir)
|
||||
|
||||
// Close writer and read captured output
|
||||
if closeErr := w.Close(); closeErr != nil {
|
||||
t.Logf(shared.TestMsgFailedToClose, closeErr)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, copyErr := io.Copy(&buf, r); copyErr != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToReadOutput, copyErr)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
// Check for error
|
||||
if err != nil {
|
||||
t.Errorf("RunAllBenchmarks failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify expected output content
|
||||
expectedContents := []string{
|
||||
"Running gibidify benchmark suite...",
|
||||
"Running file collection benchmark...",
|
||||
"Running format benchmarks...",
|
||||
"Running concurrency benchmarks...",
|
||||
}
|
||||
|
||||
for _, expected := range expectedContents {
|
||||
if !strings.Contains(output, expected) {
|
||||
t.Errorf("RunAllBenchmarks output missing expected content: %q\nFull output:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
// The function should not panic and should complete successfully
|
||||
t.Log("RunAllBenchmarks completed successfully with output captured")
|
||||
}
|
||||
295
cli/errors.go
Normal file
295
cli/errors.go
Normal file
@@ -0,0 +1,295 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// ErrorFormatter handles CLI-friendly error formatting with suggestions.
|
||||
// This is not an error type itself; it formats existing errors for display.
|
||||
type ErrorFormatter struct {
|
||||
ui *UIManager
|
||||
}
|
||||
|
||||
// NewErrorFormatter creates a new error formatter.
|
||||
func NewErrorFormatter(ui *UIManager) *ErrorFormatter {
|
||||
return &ErrorFormatter{ui: ui}
|
||||
}
|
||||
|
||||
// FormatError formats an error with context and suggestions.
|
||||
func (ef *ErrorFormatter) FormatError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle structured errors
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
ef.formatStructuredError(structErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Handle common error types
|
||||
ef.formatGenericError(err)
|
||||
}
|
||||
|
||||
// formatStructuredError formats a structured error with context and suggestions.
|
||||
func (ef *ErrorFormatter) formatStructuredError(err *shared.StructuredError) {
|
||||
// Print main error
|
||||
ef.ui.PrintError(shared.CLIMsgErrorFormat, err.Message)
|
||||
|
||||
// Print error type and code
|
||||
if err.Type != shared.ErrorTypeUnknown || err.Code != "" {
|
||||
ef.ui.PrintInfo("Type: %s, Code: %s", err.Type.String(), err.Code)
|
||||
}
|
||||
|
||||
// Print file path if available
|
||||
if err.FilePath != "" {
|
||||
ef.ui.PrintInfo("File: %s", err.FilePath)
|
||||
}
|
||||
|
||||
// Print context if available
|
||||
if len(err.Context) > 0 {
|
||||
ef.ui.PrintInfo("Context:")
|
||||
for key, value := range err.Context {
|
||||
ef.ui.printf(" %s: %v\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Provide suggestions based on error type
|
||||
ef.provideSuggestions(err)
|
||||
}
|
||||
|
||||
// formatGenericError formats a generic error.
|
||||
func (ef *ErrorFormatter) formatGenericError(err error) {
|
||||
ef.ui.PrintError(shared.CLIMsgErrorFormat, err.Error())
|
||||
ef.provideGenericSuggestions(err)
|
||||
}
|
||||
|
||||
// provideSuggestions provides helpful suggestions based on the error.
|
||||
func (ef *ErrorFormatter) provideSuggestions(err *shared.StructuredError) {
|
||||
switch err.Type {
|
||||
case shared.ErrorTypeFileSystem:
|
||||
ef.provideFileSystemSuggestions(err)
|
||||
case shared.ErrorTypeValidation:
|
||||
ef.provideValidationSuggestions(err)
|
||||
case shared.ErrorTypeProcessing:
|
||||
ef.provideProcessingSuggestions(err)
|
||||
case shared.ErrorTypeIO:
|
||||
ef.provideIOSuggestions(err)
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
// provideFileSystemSuggestions provides suggestions for file system errors.
|
||||
func (ef *ErrorFormatter) provideFileSystemSuggestions(err *shared.StructuredError) {
|
||||
filePath := err.FilePath
|
||||
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
case shared.CodeFSAccess:
|
||||
ef.suggestFileAccess(filePath)
|
||||
case shared.CodeFSPathResolution:
|
||||
ef.suggestPathResolution(filePath)
|
||||
case shared.CodeFSNotFound:
|
||||
ef.suggestFileNotFound(filePath)
|
||||
default:
|
||||
ef.suggestFileSystemGeneral(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// provideValidationSuggestions provides suggestions for validation errors.
|
||||
func (ef *ErrorFormatter) provideValidationSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
case shared.CodeValidationFormat:
|
||||
ef.ui.printf(" • Use a supported format: markdown, json, yaml\n")
|
||||
ef.ui.printf(" • Example: -format markdown\n")
|
||||
case shared.CodeValidationSize:
|
||||
ef.ui.printf(" • Increase file size limit in config.yaml\n")
|
||||
ef.ui.printf(" • Use smaller files or exclude large files\n")
|
||||
default:
|
||||
ef.ui.printf(shared.CLIMsgCheckCommandLineArgs)
|
||||
ef.ui.printf(shared.CLIMsgRunWithHelp)
|
||||
}
|
||||
}
|
||||
|
||||
// provideProcessingSuggestions provides suggestions for processing errors.
|
||||
func (ef *ErrorFormatter) provideProcessingSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
case shared.CodeProcessingCollection:
|
||||
ef.ui.printf(" • Check if the source directory exists and is readable\n")
|
||||
ef.ui.printf(" • Verify directory permissions\n")
|
||||
case shared.CodeProcessingFileRead:
|
||||
ef.ui.printf(" • Check file permissions\n")
|
||||
ef.ui.printf(" • Verify the file is not corrupted\n")
|
||||
default:
|
||||
ef.ui.printf(" • Try reducing concurrency: -concurrency 1\n")
|
||||
ef.ui.printf(" • Check available system resources\n")
|
||||
}
|
||||
}
|
||||
|
||||
// provideIOSuggestions provides suggestions for I/O errors.
|
||||
func (ef *ErrorFormatter) provideIOSuggestions(err *shared.StructuredError) {
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
switch err.Code {
|
||||
case shared.CodeIOFileCreate:
|
||||
ef.ui.printf(" • Check if the destination directory exists\n")
|
||||
ef.ui.printf(" • Verify write permissions for the output file\n")
|
||||
ef.ui.printf(" • Ensure sufficient disk space\n")
|
||||
case shared.CodeIOWrite:
|
||||
ef.ui.printf(" • Check available disk space\n")
|
||||
ef.ui.printf(" • Verify write permissions\n")
|
||||
default:
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Verify available disk space\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for specific suggestions.
|
||||
func (ef *ErrorFormatter) suggestFileAccess(filePath string) {
|
||||
ef.ui.printf(" • Check if the path exists: %s\n", filePath)
|
||||
ef.ui.printf(" • Verify read permissions\n")
|
||||
if filePath != "" {
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
ef.ui.printf(" • Path exists but may not be accessible\n")
|
||||
ef.ui.printf(" • Mode: %s\n", stat.Mode())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestPathResolution(filePath string) {
|
||||
ef.ui.printf(" • Use an absolute path instead of relative\n")
|
||||
if filePath != "" {
|
||||
if abs, err := filepath.Abs(filePath); err == nil {
|
||||
ef.ui.printf(" • Try: %s\n", abs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileNotFound(filePath string) {
|
||||
ef.ui.printf(" • Check if the file/directory exists: %s\n", filePath)
|
||||
if filePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ef.ui.printf(" • Similar files in %s:\n", dir)
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if count >= 3 {
|
||||
break
|
||||
}
|
||||
if strings.Contains(entry.Name(), filepath.Base(filePath)) {
|
||||
ef.ui.printf(" - %s\n", entry.Name())
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ef *ErrorFormatter) suggestFileSystemGeneral(filePath string) {
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Verify the path is correct\n")
|
||||
if filePath != "" {
|
||||
ef.ui.printf(" • Path: %s\n", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// provideDefaultSuggestions provides general suggestions.
|
||||
func (ef *ErrorFormatter) provideDefaultSuggestions() {
|
||||
ef.ui.printf(shared.CLIMsgCheckCommandLineArgs)
|
||||
ef.ui.printf(shared.CLIMsgRunWithHelp)
|
||||
ef.ui.printf(" • Try with -concurrency 1 to reduce resource usage\n")
|
||||
}
|
||||
|
||||
// provideGenericSuggestions provides suggestions for generic errors.
|
||||
func (ef *ErrorFormatter) provideGenericSuggestions(err error) {
|
||||
errorMsg := err.Error()
|
||||
|
||||
ef.ui.PrintWarning(shared.CLIMsgSuggestions)
|
||||
|
||||
// Pattern matching for common errors
|
||||
switch {
|
||||
case strings.Contains(errorMsg, "permission denied"):
|
||||
ef.ui.printf(shared.CLIMsgCheckFilePermissions)
|
||||
ef.ui.printf(" • Try running with appropriate privileges\n")
|
||||
case strings.Contains(errorMsg, "no such file or directory"):
|
||||
ef.ui.printf(" • Verify the file/directory path is correct\n")
|
||||
ef.ui.printf(" • Check if the file exists\n")
|
||||
case strings.Contains(errorMsg, "flag") && strings.Contains(errorMsg, "redefined"):
|
||||
ef.ui.printf(" • This is likely a test environment issue\n")
|
||||
ef.ui.printf(" • Try running the command directly instead of in tests\n")
|
||||
default:
|
||||
ef.provideDefaultSuggestions()
|
||||
}
|
||||
}
|
||||
|
||||
// CLI-specific error types
|
||||
|
||||
// MissingSourceError represents a missing source directory error.
|
||||
type MissingSourceError struct{}
|
||||
|
||||
func (e MissingSourceError) Error() string {
|
||||
return "source directory is required"
|
||||
}
|
||||
|
||||
// NewCLIMissingSourceError creates a new CLI missing source error with suggestions.
|
||||
func NewCLIMissingSourceError() error {
|
||||
return &MissingSourceError{}
|
||||
}
|
||||
|
||||
// IsUserError checks if an error is a user input error that should be handled gracefully.
|
||||
func IsUserError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for specific user error types
|
||||
var cliErr *MissingSourceError
|
||||
if errors.As(err, &cliErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for structured errors that are user-facing
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
return structErr.Type == shared.ErrorTypeValidation ||
|
||||
structErr.Code == shared.CodeValidationFormat ||
|
||||
structErr.Code == shared.CodeValidationSize
|
||||
}
|
||||
|
||||
// Check error message patterns
|
||||
errMsg := err.Error()
|
||||
userErrorPatterns := []string{
|
||||
"flag",
|
||||
"usage",
|
||||
"invalid argument",
|
||||
"file not found",
|
||||
"permission denied",
|
||||
}
|
||||
|
||||
for _, pattern := range userErrorPatterns {
|
||||
if strings.Contains(strings.ToLower(errMsg), pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
744
cli/errors_test.go
Normal file
744
cli/errors_test.go
Normal file
@@ -0,0 +1,744 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
func TestNewErrorFormatter(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
if formatter == nil {
|
||||
t.Error("NewErrorFormatter() returned nil")
|
||||
|
||||
return
|
||||
}
|
||||
if formatter.ui != ui {
|
||||
t.Error("NewErrorFormatter() did not set ui manager correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatterFormatError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expectedOutput []string // Substrings that should be present in output
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expectedOutput: []string{}, // Should produce no output
|
||||
},
|
||||
{
|
||||
name: "structured error with context",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSAccess,
|
||||
Message: shared.TestErrCannotAccessFile,
|
||||
FilePath: shared.TestPathBase,
|
||||
Context: map[string]any{
|
||||
"permission": "0000",
|
||||
"owner": "root",
|
||||
},
|
||||
},
|
||||
expectedOutput: []string{
|
||||
"✗ Error: " + shared.TestErrCannotAccessFile,
|
||||
"Type: FileSystem, Code: ACCESS_DENIED",
|
||||
"File: " + shared.TestPathBase,
|
||||
"Context:",
|
||||
"permission: 0000",
|
||||
"owner: root",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Check if the path exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeValidation,
|
||||
Code: shared.CodeValidationFormat,
|
||||
Message: "invalid output format",
|
||||
},
|
||||
expectedOutput: []string{
|
||||
"✗ Error: invalid output format",
|
||||
"Type: Validation, Code: FORMAT",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Use a supported format: markdown, json, yaml",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "processing error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeProcessing,
|
||||
Code: shared.CodeProcessingCollection,
|
||||
Message: "failed to collect files",
|
||||
},
|
||||
expectedOutput: []string{
|
||||
"✗ Error: failed to collect files",
|
||||
"Type: Processing, Code: COLLECTION",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Check if the source directory exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "I/O error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeIO,
|
||||
Code: shared.CodeIOFileCreate,
|
||||
Message: "cannot create output file",
|
||||
},
|
||||
expectedOutput: []string{
|
||||
"✗ Error: cannot create output file",
|
||||
"Type: IO, Code: FILE_CREATE",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Check if the destination directory exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic error with permission denied",
|
||||
err: errors.New("permission denied: access to /secret/file"),
|
||||
expectedOutput: []string{
|
||||
"✗ Error: permission denied: access to /secret/file",
|
||||
shared.TestSuggestionsWarning,
|
||||
shared.TestSuggestCheckPermissions,
|
||||
"Try running with appropriate privileges",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic error with file not found",
|
||||
err: errors.New("no such file or directory"),
|
||||
expectedOutput: []string{
|
||||
"✗ Error: no such file or directory",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Verify the file/directory path is correct",
|
||||
"Check if the file exists",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic error with flag redefined",
|
||||
err: errors.New("flag provided but not defined: -invalid"),
|
||||
expectedOutput: []string{
|
||||
"✗ Error: flag provided but not defined: -invalid",
|
||||
shared.TestSuggestionsWarning,
|
||||
shared.TestSuggestCheckArguments,
|
||||
"Run with --help for usage information",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown generic error",
|
||||
err: errors.New("some unknown error"),
|
||||
expectedOutput: []string{
|
||||
"✗ Error: some unknown error",
|
||||
shared.TestSuggestionsWarning,
|
||||
shared.TestSuggestCheckArguments,
|
||||
"Run with --help for usage information",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture output
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
formatter.FormatError(tt.err)
|
||||
|
||||
outputStr := output.String()
|
||||
|
||||
// For nil error, output should be empty
|
||||
if tt.err == nil {
|
||||
if outputStr != "" {
|
||||
t.Errorf("Expected no output for nil error, got: %s", outputStr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Check that all expected substrings are present
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatterSuggestFileAccess(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
// Create a temporary file to test with existing file
|
||||
tempDir := t.TempDir()
|
||||
tempFile, err := os.Create(filepath.Join(tempDir, "testfile"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
t.Errorf("Failed to close temp file: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: shared.TestErrEmptyFilePath,
|
||||
filePath: "",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckExists,
|
||||
"Verify read permissions",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "existing file",
|
||||
filePath: tempFile.Name(),
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckExists,
|
||||
"Path exists but may not be accessible",
|
||||
"Mode:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nonexistent file",
|
||||
filePath: "/nonexistent/file",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckExists,
|
||||
"Verify read permissions",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output.Reset()
|
||||
formatter.suggestFileAccess(tt.filePath)
|
||||
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatterSuggestFileNotFound(t *testing.T) {
|
||||
// Create a test directory with some files
|
||||
tempDir := t.TempDir()
|
||||
testFiles := []string{"similar-file.txt", "another-similar.go", "different.md"}
|
||||
for _, filename := range testFiles {
|
||||
file, err := os.Create(filepath.Join(tempDir, filename))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Errorf("Failed to close test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: shared.TestErrEmptyFilePath,
|
||||
filePath: "",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckFileExists,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file with similar matches",
|
||||
filePath: tempDir + "/similar",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckFileExists,
|
||||
"Similar files in",
|
||||
"similar-file.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nonexistent directory",
|
||||
filePath: "/nonexistent/dir/file.txt",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckFileExists,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output.Reset()
|
||||
formatter.suggestFileNotFound(tt.filePath)
|
||||
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorFormatterProvideSuggestions(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *shared.StructuredError
|
||||
expectSuggestions []string
|
||||
}{
|
||||
{
|
||||
name: "filesystem error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSAccess,
|
||||
},
|
||||
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check if the path exists"},
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeValidation,
|
||||
Code: shared.CodeValidationFormat,
|
||||
},
|
||||
expectSuggestions: []string{shared.TestSuggestionsPlain, "Use a supported format"},
|
||||
},
|
||||
{
|
||||
name: "processing error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeProcessing,
|
||||
Code: shared.CodeProcessingCollection,
|
||||
},
|
||||
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check if the source directory exists"},
|
||||
},
|
||||
{
|
||||
name: "I/O error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeIO,
|
||||
Code: shared.CodeIOWrite,
|
||||
},
|
||||
expectSuggestions: []string{shared.TestSuggestionsPlain, "Check available disk space"},
|
||||
},
|
||||
{
|
||||
name: "unknown error type",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeUnknown,
|
||||
},
|
||||
expectSuggestions: []string{"Check your command line arguments"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output.Reset()
|
||||
formatter.provideSuggestions(tt.err)
|
||||
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectSuggestions {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf(shared.TestMsgOutputMissingSubstring, expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSourceError(t *testing.T) {
|
||||
err := NewCLIMissingSourceError()
|
||||
|
||||
if err == nil {
|
||||
t.Error("NewCLIMissingSourceError() returned nil")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
expectedMsg := "source directory is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("MissingSourceError.Error() = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
|
||||
// Test type assertion
|
||||
var cliErr *MissingSourceError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Error("NewCLIMissingSourceError() did not return *MissingSourceError type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "CLI missing source error",
|
||||
err: NewCLIMissingSourceError(),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "validation structured error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeValidation,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "validation format structured error",
|
||||
err: &shared.StructuredError{
|
||||
Code: shared.CodeValidationFormat,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "validation size structured error",
|
||||
err: &shared.StructuredError{
|
||||
Code: shared.CodeValidationSize,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-validation structured error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "generic error with flag keyword",
|
||||
err: errors.New("flag provided but not defined"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generic error with usage keyword",
|
||||
err: errors.New("usage: command [options]"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generic error with invalid argument",
|
||||
err: errors.New("invalid argument provided"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generic error with file not found",
|
||||
err: errors.New("file not found"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generic error with permission denied",
|
||||
err: errors.New("permission denied"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "system error not user-facing",
|
||||
err: errors.New("internal system error"),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsUserError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsUserError(%v) = %v, want %v", tt.err, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for testing
|
||||
|
||||
// createTestUI creates a UIManager with captured output for testing.
|
||||
func createTestUI() (*UIManager, *bytes.Buffer) {
|
||||
output := &bytes.Buffer{}
|
||||
ui := &UIManager{
|
||||
enableColors: false, // Disable colors for consistent testing
|
||||
enableProgress: false, // Disable progress for testing
|
||||
output: output,
|
||||
}
|
||||
|
||||
return ui, output
|
||||
}
|
||||
|
||||
// TestErrorFormatterIntegration tests the complete error formatting workflow.
|
||||
func TestErrorFormatterIntegration(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
// Test a complete workflow with a complex structured error
|
||||
structuredErr := &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSNotFound,
|
||||
Message: "source directory not found",
|
||||
FilePath: "/missing/directory",
|
||||
Context: map[string]any{
|
||||
"attempted_path": "/missing/directory",
|
||||
"current_dir": "/working/dir",
|
||||
},
|
||||
}
|
||||
|
||||
formatter.FormatError(structuredErr)
|
||||
outputStr := output.String()
|
||||
|
||||
// Verify all components are present
|
||||
expectedComponents := []string{
|
||||
"✗ Error: source directory not found",
|
||||
"Type: FileSystem, Code: NOT_FOUND",
|
||||
"File: /missing/directory",
|
||||
"Context:",
|
||||
"attempted_path: /missing/directory",
|
||||
"current_dir: /working/dir",
|
||||
shared.TestSuggestionsWarning,
|
||||
"Check if the file/directory exists",
|
||||
}
|
||||
|
||||
for _, expected := range expectedComponents {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("Integration test output missing expected component: %q\nFull output:\n%s", expected, outputStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorFormatter_SuggestPathResolution tests the suggestPathResolution function.
|
||||
func TestErrorFormatterSuggestPathResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "with file path",
|
||||
filePath: "relative/path/file.txt",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestUseAbsolutePath,
|
||||
"Try:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: shared.TestErrEmptyFilePath,
|
||||
filePath: "",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestUseAbsolutePath,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "current directory reference",
|
||||
filePath: "./file.txt",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestUseAbsolutePath,
|
||||
"Try:",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
// Call the method
|
||||
formatter.suggestPathResolution(tt.filePath)
|
||||
|
||||
// Check output
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("suggestPathResolution output missing: %q\nFull output: %q", expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorFormatter_SuggestFileSystemGeneral tests the suggestFileSystemGeneral function.
|
||||
func TestErrorFormatterSuggestFileSystemGeneral(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "with file path",
|
||||
filePath: "/path/to/file.txt",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckPermissions,
|
||||
shared.TestSuggestVerifyPath,
|
||||
"Path: /path/to/file.txt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: shared.TestErrEmptyFilePath,
|
||||
filePath: "",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckPermissions,
|
||||
shared.TestSuggestVerifyPath,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relative path",
|
||||
filePath: "../parent/file.txt",
|
||||
expectedOutput: []string{
|
||||
shared.TestSuggestCheckPermissions,
|
||||
shared.TestSuggestVerifyPath,
|
||||
"Path: ../parent/file.txt",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
// Call the method
|
||||
formatter.suggestFileSystemGeneral(tt.filePath)
|
||||
|
||||
// Check output
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("suggestFileSystemGeneral output missing: %q\nFull output: %q", expected, outputStr)
|
||||
}
|
||||
}
|
||||
|
||||
// When no file path is provided, should not contain "Path:" line
|
||||
if tt.filePath == "" && strings.Contains(outputStr, "Path:") {
|
||||
t.Error("suggestFileSystemGeneral should not include Path line when filePath is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorFormatter_SuggestionFunctions_Integration tests the integration of suggestion functions.
|
||||
func TestErrorFormatterSuggestionFunctionsIntegration(t *testing.T) {
|
||||
// Test that suggestion functions work as part of the full error formatting workflow
|
||||
tests := []struct {
|
||||
name string
|
||||
err *shared.StructuredError
|
||||
expectedSuggestions []string
|
||||
}{
|
||||
{
|
||||
name: "filesystem path resolution error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSPathResolution,
|
||||
Message: "path resolution failed",
|
||||
FilePath: "relative/path",
|
||||
},
|
||||
expectedSuggestions: []string{
|
||||
shared.TestSuggestUseAbsolutePath,
|
||||
"Try:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filesystem unknown error",
|
||||
err: &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: "UNKNOWN_FS_ERROR", // This will trigger default case
|
||||
Message: "unknown filesystem error",
|
||||
FilePath: "/some/path",
|
||||
},
|
||||
expectedSuggestions: []string{
|
||||
shared.TestSuggestCheckPermissions,
|
||||
shared.TestSuggestVerifyPath,
|
||||
"Path: /some/path",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
|
||||
// Format the error (which should include suggestions)
|
||||
formatter.FormatError(tt.err)
|
||||
|
||||
// Check that expected suggestions are present
|
||||
outputStr := output.String()
|
||||
for _, expected := range tt.expectedSuggestions {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("Integrated suggestion missing: %q\nFull output: %q", expected, outputStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks for error formatting performance
|
||||
|
||||
// BenchmarkErrorFormatterFormatError benchmarks the FormatError method.
|
||||
func BenchmarkErrorFormatterFormatError(b *testing.B) {
|
||||
ui, _ := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
err := &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSAccess,
|
||||
Message: shared.TestErrCannotAccessFile,
|
||||
FilePath: shared.TestPathBase,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
formatter.FormatError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkErrorFormatterFormatErrorWithContext benchmarks error formatting with context.
|
||||
func BenchmarkErrorFormatterFormatErrorWithContext(b *testing.B) {
|
||||
ui, _ := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
err := &shared.StructuredError{
|
||||
Type: shared.ErrorTypeValidation,
|
||||
Code: shared.CodeValidationFormat,
|
||||
Message: "validation failed",
|
||||
FilePath: shared.TestPathBase,
|
||||
Context: map[string]any{
|
||||
"field": "format",
|
||||
"value": "invalid",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
formatter.FormatError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkErrorFormatterProvideSuggestions benchmarks suggestion generation.
|
||||
func BenchmarkErrorFormatterProvideSuggestions(b *testing.B) {
|
||||
ui, _ := createTestUI()
|
||||
formatter := NewErrorFormatter(ui)
|
||||
err := &shared.StructuredError{
|
||||
Type: shared.ErrorTypeFileSystem,
|
||||
Code: shared.CodeFSAccess,
|
||||
Message: shared.TestErrCannotAccessFile,
|
||||
FilePath: shared.TestPathBase,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
formatter.provideSuggestions(err)
|
||||
}
|
||||
}
|
||||
128
cli/flags.go
Normal file
128
cli/flags.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Flags holds CLI flags values.
|
||||
type Flags struct {
|
||||
SourceDir string
|
||||
Destination string
|
||||
Prefix string
|
||||
Suffix string
|
||||
Concurrency int
|
||||
Format string
|
||||
NoColors bool
|
||||
NoProgress bool
|
||||
NoUI bool
|
||||
Verbose bool
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
var (
|
||||
flagsParsed bool
|
||||
globalFlags *Flags
|
||||
)
|
||||
|
||||
// ResetFlags resets the global flag parsing state for testing.
|
||||
// This function should only be used in tests to ensure proper isolation.
|
||||
func ResetFlags() {
|
||||
flagsParsed = false
|
||||
globalFlags = nil
|
||||
// Reset default FlagSet to avoid duplicate flag registration across tests
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}
|
||||
|
||||
// ParseFlags parses and validates CLI flags.
|
||||
func ParseFlags() (*Flags, error) {
|
||||
if flagsParsed {
|
||||
return globalFlags, nil
|
||||
}
|
||||
|
||||
flags := &Flags{}
|
||||
|
||||
flag.StringVar(&flags.SourceDir, shared.CLIArgSource, "", "Source directory to scan recursively")
|
||||
flag.StringVar(&flags.Destination, "destination", "", "Output file to write aggregated code")
|
||||
flag.StringVar(&flags.Prefix, "prefix", "", "Text to add at the beginning of the output file")
|
||||
flag.StringVar(&flags.Suffix, "suffix", "", "Text to add at the end of the output file")
|
||||
flag.StringVar(&flags.Format, shared.CLIArgFormat, shared.FormatJSON, "Output format (json, markdown, yaml)")
|
||||
flag.IntVar(&flags.Concurrency, shared.CLIArgConcurrency, runtime.NumCPU(),
|
||||
"Number of concurrent workers (default: number of CPU cores)")
|
||||
flag.BoolVar(&flags.NoColors, "no-colors", false, "Disable colored output")
|
||||
flag.BoolVar(&flags.NoProgress, "no-progress", false, "Disable progress bars")
|
||||
flag.BoolVar(&flags.NoUI, "no-ui", false, "Disable all UI output (implies no-colors and no-progress)")
|
||||
flag.BoolVar(&flags.Verbose, "verbose", false, "Enable verbose output")
|
||||
flag.StringVar(
|
||||
&flags.LogLevel, "log-level", string(shared.LogLevelWarn), "Set log level (debug, info, warn, error)",
|
||||
)
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if err := flags.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := flags.setDefaultDestination(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagsParsed = true
|
||||
globalFlags = flags
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
// validate validates the CLI flags.
|
||||
func (f *Flags) validate() error {
|
||||
if f.SourceDir == "" {
|
||||
return NewCLIMissingSourceError()
|
||||
}
|
||||
|
||||
// Validate source path for security
|
||||
if err := shared.ValidateSourcePath(f.SourceDir); err != nil {
|
||||
return fmt.Errorf("validating source path: %w", err)
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
if err := config.ValidateOutputFormat(f.Format); err != nil {
|
||||
return fmt.Errorf("validating output format: %w", err)
|
||||
}
|
||||
|
||||
// Validate concurrency
|
||||
if err := config.ValidateConcurrency(f.Concurrency); err != nil {
|
||||
return fmt.Errorf("validating concurrency: %w", err)
|
||||
}
|
||||
|
||||
// Validate log level
|
||||
if !shared.ValidateLogLevel(f.LogLevel) {
|
||||
return fmt.Errorf("invalid log level: %s (must be: debug, info, warn, error)", f.LogLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDefaultDestination sets the default destination if not provided.
|
||||
func (f *Flags) setDefaultDestination() error {
|
||||
if f.Destination == "" {
|
||||
absRoot, err := shared.AbsolutePath(f.SourceDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting absolute path: %w", err)
|
||||
}
|
||||
baseName := shared.BaseName(absRoot)
|
||||
f.Destination = baseName + "." + f.Format
|
||||
}
|
||||
|
||||
// Validate destination path for security
|
||||
if err := shared.ValidateDestinationPath(f.Destination); err != nil {
|
||||
return fmt.Errorf("validating destination path: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
664
cli/flags_test.go
Normal file
664
cli/flags_test.go
Normal file
@@ -0,0 +1,664 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
const testDirPlaceholder = "testdir"
|
||||
|
||||
// setupTestArgs prepares test arguments by replacing testdir with actual temp directory.
|
||||
func setupTestArgs(t *testing.T, args []string, want *Flags) ([]string, *Flags) {
|
||||
t.Helper()
|
||||
|
||||
if !containsFlag(args, shared.TestCLIFlagSource) {
|
||||
return args, want
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
modifiedArgs := replaceTestDirInArgs(args, tempDir)
|
||||
|
||||
// Handle nil want parameter (used for error test cases)
|
||||
if want == nil {
|
||||
return modifiedArgs, nil
|
||||
}
|
||||
|
||||
modifiedWant := updateWantFlags(*want, tempDir)
|
||||
|
||||
return modifiedArgs, &modifiedWant
|
||||
}
|
||||
|
||||
// replaceTestDirInArgs replaces testdir placeholder with actual temp directory in args.
|
||||
func replaceTestDirInArgs(args []string, tempDir string) []string {
|
||||
modifiedArgs := make([]string, len(args))
|
||||
copy(modifiedArgs, args)
|
||||
|
||||
for i, arg := range modifiedArgs {
|
||||
if arg == testDirPlaceholder {
|
||||
modifiedArgs[i] = tempDir
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedArgs
|
||||
}
|
||||
|
||||
// updateWantFlags updates the want flags with temp directory replacements.
|
||||
func updateWantFlags(want Flags, tempDir string) Flags {
|
||||
modifiedWant := want
|
||||
|
||||
if want.SourceDir == testDirPlaceholder {
|
||||
modifiedWant.SourceDir = tempDir
|
||||
if strings.HasPrefix(want.Destination, testDirPlaceholder+".") {
|
||||
baseName := testutil.BaseName(tempDir)
|
||||
modifiedWant.Destination = baseName + "." + want.Format
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedWant
|
||||
}
|
||||
|
||||
// runParseFlagsTest runs a single parse flags test.
|
||||
func runParseFlagsTest(t *testing.T, args []string, want *Flags, wantErr bool, errContains string) {
|
||||
t.Helper()
|
||||
|
||||
// Capture and restore original os.Args
|
||||
origArgs := os.Args
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
resetFlagsState()
|
||||
modifiedArgs, modifiedWant := setupTestArgs(t, args, want)
|
||||
setupCommandLineArgs(modifiedArgs)
|
||||
|
||||
got, err := ParseFlags()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("ParseFlags() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("ParseFlags() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseFlags() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
verifyFlags(t, got, modifiedWant)
|
||||
}
|
||||
|
||||
func TestParseFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want *Flags
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid basic flags",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagFormat, "markdown"},
|
||||
want: &Flags{
|
||||
SourceDir: "testdir",
|
||||
Format: "markdown",
|
||||
Concurrency: runtime.NumCPU(),
|
||||
Destination: "testdir.markdown",
|
||||
LogLevel: string(shared.LogLevelWarn),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with all flags",
|
||||
args: []string{
|
||||
shared.TestCLIFlagSource, "testdir",
|
||||
shared.TestCLIFlagDestination, shared.TestOutputMD,
|
||||
"-prefix", "# Header",
|
||||
"-suffix", "# Footer",
|
||||
shared.TestCLIFlagFormat, "json",
|
||||
shared.TestCLIFlagConcurrency, "4",
|
||||
"-verbose",
|
||||
"-no-colors",
|
||||
"-no-progress",
|
||||
},
|
||||
want: &Flags{
|
||||
SourceDir: "testdir",
|
||||
Destination: shared.TestOutputMD,
|
||||
Prefix: "# Header",
|
||||
Suffix: "# Footer",
|
||||
Format: "json",
|
||||
Concurrency: 4,
|
||||
Verbose: true,
|
||||
NoColors: true,
|
||||
NoProgress: true,
|
||||
LogLevel: string(shared.LogLevelWarn),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing source directory",
|
||||
args: []string{shared.TestCLIFlagFormat, "markdown"},
|
||||
wantErr: true,
|
||||
errContains: "source directory is required",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagFormat, "invalid"},
|
||||
wantErr: true,
|
||||
errContains: "validating output format",
|
||||
},
|
||||
{
|
||||
name: "invalid concurrency zero",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagConcurrency, "0"},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
name: "negative concurrency",
|
||||
args: []string{shared.TestCLIFlagSource, "testdir", shared.TestCLIFlagConcurrency, "-1"},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
runParseFlagsTest(t, tt.args, tt.want, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// validateFlagsValidationResult validates flag validation test results.
|
||||
func validateFlagsValidationResult(t *testing.T, err error, wantErr bool, errContains string) {
|
||||
t.Helper()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("Flags.validate() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("Flags.validate() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Flags.validate() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsvalidate(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid flags",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty source directory",
|
||||
flags: &Flags{
|
||||
Format: "markdown",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "source directory is required",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "invalid",
|
||||
Concurrency: 4,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "validating output format",
|
||||
},
|
||||
{
|
||||
name: "zero concurrency",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
Concurrency: 0,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
name: "negative concurrency",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
Concurrency: -1,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: shared.TestOpValidatingConcurrency,
|
||||
},
|
||||
{
|
||||
name: "invalid log level",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
Concurrency: 4,
|
||||
LogLevel: "invalid",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "invalid log level",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
err := tt.flags.validate()
|
||||
validateFlagsValidationResult(t, err, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// validateDefaultDestinationResult validates default destination test results.
|
||||
func validateDefaultDestinationResult(
|
||||
t *testing.T,
|
||||
flags *Flags,
|
||||
err error,
|
||||
wantDestination string,
|
||||
wantErr bool,
|
||||
errContains string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
if wantErr {
|
||||
if err == nil {
|
||||
t.Error("Flags.setDefaultDestination() expected error, got nil")
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("Flags.setDefaultDestination() error = %v, want error containing %v", err, errContains)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Flags.setDefaultDestination() unexpected error = %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if flags.Destination != wantDestination {
|
||||
t.Errorf("Flags.Destination = %v, want %v", flags.Destination, wantDestination)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagssetDefaultDestination(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
baseName := testutil.BaseName(tempDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags *Flags
|
||||
wantDestination string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "set default destination markdown",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantDestination: baseName + ".markdown",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "set default destination json",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "json",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantDestination: baseName + ".json",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "set default destination yaml",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "yaml",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantDestination: baseName + ".yaml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "preserve existing destination",
|
||||
flags: &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "yaml",
|
||||
Destination: "custom-output.yaml",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantDestination: "custom-output.yaml",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nonexistent source path still generates destination",
|
||||
flags: &Flags{
|
||||
SourceDir: "/nonexistent/path/that/should/not/exist",
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
},
|
||||
wantDestination: "exist.markdown", // Based on filepath.Base of the path
|
||||
wantErr: false, // AbsolutePath doesn't validate existence, only converts to absolute
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
err := tt.flags.setDefaultDestination()
|
||||
validateDefaultDestinationResult(t, tt.flags, err, tt.wantDestination, tt.wantErr, tt.errContains)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFlagsSingleton(t *testing.T) {
|
||||
// Capture and restore original os.Args
|
||||
origArgs := os.Args
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
resetFlagsState()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// First call
|
||||
setupCommandLineArgs([]string{shared.TestCLIFlagSource, tempDir, shared.TestCLIFlagFormat, "markdown"})
|
||||
flags1, err := ParseFlags()
|
||||
if err != nil {
|
||||
t.Fatalf("First ParseFlags() failed: %v", err)
|
||||
}
|
||||
|
||||
// Second call should return the same instance
|
||||
flags2, err := ParseFlags()
|
||||
if err != nil {
|
||||
t.Fatalf("Second ParseFlags() failed: %v", err)
|
||||
}
|
||||
|
||||
if flags1 != flags2 {
|
||||
t.Error("ParseFlags() should return singleton instance, got different pointers")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// resetFlagsState resets the global flags state for testing.
|
||||
func resetFlagsState() {
|
||||
flagsParsed = false
|
||||
globalFlags = nil
|
||||
// Reset the flag.CommandLine for clean testing (use ContinueOnError to match ResetFlags)
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
}
|
||||
|
||||
// setupCommandLineArgs sets up command line arguments for testing.
|
||||
func setupCommandLineArgs(args []string) {
|
||||
os.Args = append([]string{"gibidify"}, args...)
|
||||
}
|
||||
|
||||
// containsFlag checks if a flag is present in the arguments.
|
||||
func containsFlag(args []string, flagName string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// verifyFlags compares two Flags structs for testing.
|
||||
func verifyFlags(t *testing.T, got, want *Flags) {
|
||||
t.Helper()
|
||||
|
||||
if got.SourceDir != want.SourceDir {
|
||||
t.Errorf("SourceDir = %v, want %v", got.SourceDir, want.SourceDir)
|
||||
}
|
||||
if got.Destination != want.Destination {
|
||||
t.Errorf("Destination = %v, want %v", got.Destination, want.Destination)
|
||||
}
|
||||
if got.Prefix != want.Prefix {
|
||||
t.Errorf("Prefix = %v, want %v", got.Prefix, want.Prefix)
|
||||
}
|
||||
if got.Suffix != want.Suffix {
|
||||
t.Errorf("Suffix = %v, want %v", got.Suffix, want.Suffix)
|
||||
}
|
||||
if got.Format != want.Format {
|
||||
t.Errorf("Format = %v, want %v", got.Format, want.Format)
|
||||
}
|
||||
if got.Concurrency != want.Concurrency {
|
||||
t.Errorf("Concurrency = %v, want %v", got.Concurrency, want.Concurrency)
|
||||
}
|
||||
if got.NoColors != want.NoColors {
|
||||
t.Errorf("NoColors = %v, want %v", got.NoColors, want.NoColors)
|
||||
}
|
||||
if got.NoProgress != want.NoProgress {
|
||||
t.Errorf("NoProgress = %v, want %v", got.NoProgress, want.NoProgress)
|
||||
}
|
||||
if got.Verbose != want.Verbose {
|
||||
t.Errorf("Verbose = %v, want %v", got.Verbose, want.Verbose)
|
||||
}
|
||||
if got.LogLevel != want.LogLevel {
|
||||
t.Errorf("LogLevel = %v, want %v", got.LogLevel, want.LogLevel)
|
||||
}
|
||||
if got.NoUI != want.NoUI {
|
||||
t.Errorf("NoUI = %v, want %v", got.NoUI, want.NoUI)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFlags tests the ResetFlags function.
|
||||
func TestResetFlags(t *testing.T) {
|
||||
// Save original state
|
||||
originalArgs := os.Args
|
||||
originalFlagsParsed := flagsParsed
|
||||
originalGlobalFlags := globalFlags
|
||||
originalCommandLine := flag.CommandLine
|
||||
|
||||
defer func() {
|
||||
// Restore original state
|
||||
os.Args = originalArgs
|
||||
flagsParsed = originalFlagsParsed
|
||||
globalFlags = originalGlobalFlags
|
||||
flag.CommandLine = originalCommandLine
|
||||
}()
|
||||
|
||||
// Simplified test cases to reduce complexity
|
||||
testCases := map[string]func(t *testing.T){
|
||||
"reset after flags have been parsed": func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
testutil.CreateTestFile(t, srcDir, "test.txt", []byte("test"))
|
||||
os.Args = []string{"test", "-source", srcDir, "-destination", "out.json"}
|
||||
|
||||
// Parse flags first
|
||||
if _, err := ParseFlags(); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
"reset with clean state": func(t *testing.T) {
|
||||
if flagsParsed {
|
||||
t.Log("Note: flagsParsed was already true at start")
|
||||
}
|
||||
},
|
||||
"multiple resets": func(t *testing.T) {
|
||||
srcDir := t.TempDir()
|
||||
testutil.CreateTestFile(t, srcDir, "test.txt", []byte("test"))
|
||||
os.Args = []string{"test", "-source", srcDir, "-destination", "out.json"}
|
||||
|
||||
if _, err := ParseFlags(); err != nil {
|
||||
t.Fatalf("Setup failed: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
for name, setup := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Setup test scenario
|
||||
setup(t)
|
||||
|
||||
// Call ResetFlags
|
||||
ResetFlags()
|
||||
|
||||
// Basic verification that reset worked
|
||||
if flagsParsed {
|
||||
t.Error("flagsParsed should be false after ResetFlags()")
|
||||
}
|
||||
if globalFlags != nil {
|
||||
t.Error("globalFlags should be nil after ResetFlags()")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetFlags_Integration tests ResetFlags in integration scenarios.
|
||||
func TestResetFlagsIntegration(t *testing.T) {
|
||||
// This test verifies that ResetFlags properly resets the internal state
|
||||
// to allow multiple calls to ParseFlags in test scenarios.
|
||||
|
||||
// Note: This test documents the expected behavior of ResetFlags
|
||||
// The actual integration with ParseFlags is already tested in main tests
|
||||
// where ResetFlags is used to enable proper test isolation.
|
||||
|
||||
t.Run("state_reset_behavior", func(t *testing.T) {
|
||||
// Test behavior is already covered in TestResetFlags
|
||||
// This is mainly for documentation of the integration pattern
|
||||
|
||||
t.Log("ResetFlags integration behavior:")
|
||||
t.Log("1. Resets flagsParsed to false")
|
||||
t.Log("2. Sets globalFlags to nil")
|
||||
t.Log("3. Creates new flag.CommandLine FlagSet")
|
||||
t.Log("4. Allows subsequent ParseFlags calls")
|
||||
|
||||
// The actual mechanics are tested in TestResetFlags
|
||||
// This test serves to document the integration contract
|
||||
|
||||
// Reset state (this should not panic)
|
||||
ResetFlags()
|
||||
|
||||
// Verify basic state expectations
|
||||
if flagsParsed {
|
||||
t.Error("flagsParsed should be false after ResetFlags")
|
||||
}
|
||||
if globalFlags != nil {
|
||||
t.Error("globalFlags should be nil after ResetFlags")
|
||||
}
|
||||
if flag.CommandLine == nil {
|
||||
t.Error("flag.CommandLine should not be nil after ResetFlags")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmarks for flag-related operations.
|
||||
// While flag parsing is a one-time startup operation, these benchmarks
|
||||
// document baseline performance and catch regressions if parsing logic becomes more complex.
|
||||
//
|
||||
// Note: ParseFlags benchmarks are omitted because resetFlagsState() interferes with
|
||||
// Go's testing framework flags. The core operations (setDefaultDestination, validate)
|
||||
// are benchmarked instead.
|
||||
|
||||
// BenchmarkSetDefaultDestination measures the setDefaultDestination operation.
|
||||
func BenchmarkSetDefaultDestination(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
|
||||
for b.Loop() {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.setDefaultDestination()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSetDefaultDestinationAllFormats measures setDefaultDestination across all formats.
|
||||
func BenchmarkSetDefaultDestinationAllFormats(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
formats := []string{"markdown", "json", "yaml"}
|
||||
|
||||
for b.Loop() {
|
||||
for _, format := range formats {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Format: format,
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.setDefaultDestination()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFlagsValidate measures the validate operation.
|
||||
func BenchmarkFlagsValidate(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Destination: "output.md",
|
||||
Format: "markdown",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
|
||||
for b.Loop() {
|
||||
_ = flags.validate()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFlagsValidateAllFormats measures validate across all formats.
|
||||
func BenchmarkFlagsValidateAllFormats(b *testing.B) {
|
||||
tempDir := b.TempDir()
|
||||
formats := []string{"markdown", "json", "yaml"}
|
||||
|
||||
for b.Loop() {
|
||||
for _, format := range formats {
|
||||
flags := &Flags{
|
||||
SourceDir: tempDir,
|
||||
Destination: "output." + format,
|
||||
Format: format,
|
||||
LogLevel: "warn",
|
||||
}
|
||||
_ = flags.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
88
cli/processor_collection.go
Normal file
88
cli/processor_collection.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// collectFiles collects all files to be processed.
|
||||
func (p *Processor) collectFiles() ([]string, error) {
|
||||
files, err := fileproc.CollectFiles(p.flags.SourceDir)
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
"error collecting files",
|
||||
)
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
logger.Infof(shared.CLIMsgFoundFilesToProcess, len(files))
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// validateFileCollection validates the collected files against resource limits.
|
||||
func (p *Processor) validateFileCollection(files []string) error {
|
||||
if !config.ResourceLimitsEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file count limit
|
||||
maxFiles := config.MaxFiles()
|
||||
if len(files) > maxFiles {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitFiles,
|
||||
fmt.Sprintf("file count (%d) exceeds maximum limit (%d)", len(files), maxFiles),
|
||||
"",
|
||||
map[string]any{
|
||||
"file_count": len(files),
|
||||
"max_files": maxFiles,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Check total size limit (estimate)
|
||||
maxTotalSize := config.MaxTotalSize()
|
||||
totalSize := int64(0)
|
||||
oversizedFiles := 0
|
||||
|
||||
for _, filePath := range files {
|
||||
if fileInfo, err := os.Stat(filePath); err == nil {
|
||||
totalSize += fileInfo.Size()
|
||||
if totalSize > maxTotalSize {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTotalSize,
|
||||
fmt.Sprintf(
|
||||
"total file size (%d bytes) would exceed maximum limit (%d bytes)", totalSize, maxTotalSize,
|
||||
),
|
||||
"",
|
||||
map[string]any{
|
||||
"total_size": totalSize,
|
||||
"max_total_size": maxTotalSize,
|
||||
"files_checked": len(files),
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
oversizedFiles++
|
||||
}
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
if oversizedFiles > 0 {
|
||||
logger.Warnf("Could not stat %d files during pre-validation", oversizedFiles)
|
||||
}
|
||||
|
||||
logger.Infof("Pre-validation passed: %d files, %d MB total", len(files), totalSize/int64(shared.BytesPerMB))
|
||||
|
||||
return nil
|
||||
}
|
||||
128
cli/processor_processing.go
Normal file
128
cli/processor_processing.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Process executes the main file processing workflow.
|
||||
func (p *Processor) Process(ctx context.Context) error {
|
||||
// Create overall processing context with timeout
|
||||
overallCtx, overallCancel := p.resourceMonitor.CreateOverallProcessingContext(ctx)
|
||||
defer overallCancel()
|
||||
|
||||
// Configure file type registry
|
||||
p.configureFileTypes()
|
||||
|
||||
// Print startup info with colors
|
||||
p.ui.PrintHeader("🚀 Starting gibidify")
|
||||
p.ui.PrintInfo("Format: %s", p.flags.Format)
|
||||
p.ui.PrintInfo("Source: %s", p.flags.SourceDir)
|
||||
p.ui.PrintInfo("Destination: %s", p.flags.Destination)
|
||||
p.ui.PrintInfo("Workers: %d", p.flags.Concurrency)
|
||||
|
||||
// Log resource monitoring configuration
|
||||
p.resourceMonitor.LogResourceInfo()
|
||||
p.backpressure.LogBackpressureInfo()
|
||||
|
||||
// Collect files with progress indication and timing
|
||||
p.ui.PrintInfo("📁 Collecting files...")
|
||||
collectionStart := time.Now()
|
||||
files, err := p.collectFiles()
|
||||
collectionTime := time.Since(collectionStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseCollection, collectionTime)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Show collection results
|
||||
p.ui.PrintSuccess(shared.CLIMsgFoundFilesToProcess, len(files))
|
||||
|
||||
// Pre-validate file collection against resource limits
|
||||
if err := p.validateFileCollection(files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process files with overall timeout and timing
|
||||
processingStart := time.Now()
|
||||
err = p.processFiles(overallCtx, files)
|
||||
processingTime := time.Since(processingStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseProcessing, processingTime)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// processFiles processes the collected files.
|
||||
func (p *Processor) processFiles(ctx context.Context, files []string) error {
|
||||
outFile, err := p.createOutputFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
shared.LogError("Error closing output file", outFile.Close())
|
||||
}()
|
||||
|
||||
// Initialize back-pressure and channels
|
||||
p.ui.PrintInfo("⚙️ Initializing processing...")
|
||||
p.backpressure.LogBackpressureInfo()
|
||||
fileCh, writeCh := p.backpressure.CreateChannels()
|
||||
writerDone := make(chan struct{})
|
||||
|
||||
// Start writer
|
||||
go fileproc.StartWriter(outFile, writeCh, writerDone, p.flags.Format, p.flags.Prefix, p.flags.Suffix)
|
||||
|
||||
// Start workers
|
||||
var wg sync.WaitGroup
|
||||
p.startWorkers(ctx, &wg, fileCh, writeCh)
|
||||
|
||||
// Start progress bar
|
||||
p.ui.StartProgress(len(files), "📝 Processing files")
|
||||
|
||||
// Send files to workers
|
||||
if err := p.sendFiles(ctx, files, fileCh); err != nil {
|
||||
p.ui.FinishProgress()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Wait for completion with timing
|
||||
writingStart := time.Now()
|
||||
p.waitForCompletion(&wg, writeCh, writerDone)
|
||||
writingTime := time.Since(writingStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseWriting, writingTime)
|
||||
|
||||
p.ui.FinishProgress()
|
||||
|
||||
// Final cleanup with timing
|
||||
finalizeStart := time.Now()
|
||||
p.logFinalStats()
|
||||
finalizeTime := time.Since(finalizeStart)
|
||||
p.metricsCollector.RecordPhaseTime(shared.MetricsPhaseFinalize, finalizeTime)
|
||||
|
||||
p.ui.PrintSuccess("Processing completed. Output saved to %s", p.flags.Destination)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createOutputFile creates the output file.
|
||||
func (p *Processor) createOutputFile() (*os.File, error) {
|
||||
// Destination path has been validated in CLI flags validation for path traversal attempts
|
||||
outFile, err := os.Create(p.flags.Destination) // #nosec G304 - destination is validated in flags.validate()
|
||||
if err != nil {
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOFileCreate,
|
||||
"failed to create output file",
|
||||
).WithFilePath(p.flags.Destination)
|
||||
}
|
||||
|
||||
return outFile, nil
|
||||
}
|
||||
108
cli/processor_stats.go
Normal file
108
cli/processor_stats.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// logFinalStats logs back-pressure, resource usage, and processing statistics.
|
||||
func (p *Processor) logFinalStats() {
|
||||
p.logBackpressureStats()
|
||||
p.logResourceStats()
|
||||
p.finalizeAndReportMetrics()
|
||||
p.logVerboseStats()
|
||||
if p.resourceMonitor != nil {
|
||||
p.resourceMonitor.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// logBackpressureStats logs back-pressure statistics.
|
||||
func (p *Processor) logBackpressureStats() {
|
||||
// Check backpressure is non-nil before dereferencing
|
||||
if p.backpressure == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
backpressureStats := p.backpressure.Stats()
|
||||
if backpressureStats.Enabled {
|
||||
logger.Infof(
|
||||
"Back-pressure stats: processed=%d files, memory=%dMB/%dMB",
|
||||
backpressureStats.FilesProcessed,
|
||||
backpressureStats.CurrentMemoryUsage/int64(shared.BytesPerMB),
|
||||
backpressureStats.MaxMemoryUsage/int64(shared.BytesPerMB),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// logResourceStats logs resource monitoring statistics.
|
||||
func (p *Processor) logResourceStats() {
|
||||
// Check resource monitoring is enabled and monitor is non-nil before dereferencing
|
||||
if !config.ResourceLimitsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
if p.resourceMonitor == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
resourceStats := p.resourceMonitor.Metrics()
|
||||
|
||||
logger.Infof(
|
||||
"Resource stats: processed=%d files, totalSize=%dMB, avgFileSize=%.2fKB, rate=%.2f files/sec",
|
||||
resourceStats.FilesProcessed, resourceStats.TotalSizeProcessed/int64(shared.BytesPerMB),
|
||||
resourceStats.AverageFileSize/float64(shared.BytesPerKB), resourceStats.ProcessingRate,
|
||||
)
|
||||
|
||||
if len(resourceStats.ViolationsDetected) > 0 {
|
||||
logger.Warnf("Resource violations detected: %v", resourceStats.ViolationsDetected)
|
||||
}
|
||||
|
||||
if resourceStats.DegradationActive {
|
||||
logger.Warnf("Processing completed with degradation mode active")
|
||||
}
|
||||
|
||||
if resourceStats.EmergencyStopActive {
|
||||
logger.Errorf("Processing completed with emergency stop active")
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeAndReportMetrics finalizes metrics collection and displays the final report.
|
||||
func (p *Processor) finalizeAndReportMetrics() {
|
||||
if p.metricsCollector != nil {
|
||||
p.metricsCollector.Finish()
|
||||
}
|
||||
|
||||
if p.metricsReporter != nil {
|
||||
finalReport := p.metricsReporter.ReportFinal()
|
||||
if finalReport != "" && p.ui != nil {
|
||||
// Use UI manager to respect NoUI flag - remove trailing newline if present
|
||||
p.ui.PrintInfo("%s", strings.TrimSuffix(finalReport, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logVerboseStats logs detailed structured statistics when verbose mode is enabled.
|
||||
func (p *Processor) logVerboseStats() {
|
||||
if !p.flags.Verbose || p.metricsCollector == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
report := p.metricsCollector.GenerateReport()
|
||||
fields := map[string]any{
|
||||
"total_files": report.Summary.TotalFiles,
|
||||
"processed_files": report.Summary.ProcessedFiles,
|
||||
"skipped_files": report.Summary.SkippedFiles,
|
||||
"error_files": report.Summary.ErrorFiles,
|
||||
"processing_time": report.Summary.ProcessingTime,
|
||||
"files_per_second": report.Summary.FilesPerSecond,
|
||||
"bytes_per_second": report.Summary.BytesPerSecond,
|
||||
"memory_usage_mb": report.Summary.CurrentMemoryMB,
|
||||
}
|
||||
logger.WithFields(fields).Info("Processing completed with comprehensive metrics")
|
||||
}
|
||||
1025
cli/processor_test.go
Normal file
1025
cli/processor_test.go
Normal file
File diff suppressed because it is too large
Load Diff
59
cli/processor_types.go
Normal file
59
cli/processor_types.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/metrics"
|
||||
)
|
||||
|
||||
// Processor handles the main file processing logic.
|
||||
type Processor struct {
|
||||
flags *Flags
|
||||
backpressure *fileproc.BackpressureManager
|
||||
resourceMonitor *fileproc.ResourceMonitor
|
||||
ui *UIManager
|
||||
metricsCollector *metrics.Collector
|
||||
metricsReporter *metrics.Reporter
|
||||
}
|
||||
|
||||
// NewProcessor creates a new processor with the given flags.
|
||||
func NewProcessor(flags *Flags) *Processor {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Configure UI based on flags
|
||||
ui.SetColorOutput(!flags.NoColors && !flags.NoUI)
|
||||
ui.SetProgressOutput(!flags.NoProgress && !flags.NoUI)
|
||||
ui.SetSilentMode(flags.NoUI)
|
||||
|
||||
// Initialize metrics system
|
||||
metricsCollector := metrics.NewCollector()
|
||||
metricsReporter := metrics.NewReporter(
|
||||
metricsCollector,
|
||||
flags.Verbose && !flags.NoUI,
|
||||
!flags.NoColors && !flags.NoUI,
|
||||
)
|
||||
|
||||
return &Processor{
|
||||
flags: flags,
|
||||
backpressure: fileproc.NewBackpressureManager(),
|
||||
resourceMonitor: fileproc.NewResourceMonitor(),
|
||||
ui: ui,
|
||||
metricsCollector: metricsCollector,
|
||||
metricsReporter: metricsReporter,
|
||||
}
|
||||
}
|
||||
|
||||
// configureFileTypes configures the file type registry.
|
||||
func (p *Processor) configureFileTypes() {
|
||||
if config.FileTypesEnabled() {
|
||||
fileproc.ConfigureFromSettings(
|
||||
config.CustomImageExtensions(),
|
||||
config.CustomBinaryExtensions(),
|
||||
config.CustomLanguages(),
|
||||
config.DisabledImageExtensions(),
|
||||
config.DisabledBinaryExtensions(),
|
||||
config.DisabledLanguageExtensions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
220
cli/processor_workers.go
Normal file
220
cli/processor_workers.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/metrics"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// startWorkers starts the worker goroutines.
|
||||
func (p *Processor) startWorkers(
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
fileCh chan string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
) {
|
||||
for range p.flags.Concurrency {
|
||||
wg.Add(1)
|
||||
go p.worker(ctx, wg, fileCh, writeCh)
|
||||
}
|
||||
}
|
||||
|
||||
// worker is the worker goroutine function.
|
||||
func (p *Processor) worker(
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
fileCh chan string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case filePath, ok := <-fileCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.processFile(ctx, filePath, writeCh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processFile processes a single file with resource monitoring and metrics collection.
|
||||
func (p *Processor) processFile(ctx context.Context, filePath string, writeCh chan fileproc.WriteRequest) {
|
||||
// Create file processing context with timeout (resourceMonitor may be nil)
|
||||
fileCtx, fileCancel := ctx, func() {}
|
||||
if p.resourceMonitor != nil {
|
||||
fileCtx, fileCancel = p.resourceMonitor.CreateFileProcessingContext(ctx)
|
||||
}
|
||||
defer fileCancel()
|
||||
|
||||
// Track concurrency
|
||||
if p.metricsCollector != nil {
|
||||
p.metricsCollector.IncrementConcurrency()
|
||||
defer p.metricsCollector.DecrementConcurrency()
|
||||
}
|
||||
|
||||
// Check for emergency stop
|
||||
if p.resourceMonitor != nil && p.resourceMonitor.IsEmergencyStopActive() {
|
||||
logger := shared.GetLogger()
|
||||
logger.Warnf("Emergency stop active, skipping file: %s", filePath)
|
||||
|
||||
// Record skipped file
|
||||
p.recordFileResult(filePath, 0, "", false, true, "emergency stop active", nil)
|
||||
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
absRoot, err := shared.AbsolutePath(p.flags.SourceDir)
|
||||
if err != nil {
|
||||
shared.LogError("Failed to get absolute path", err)
|
||||
|
||||
// Record error
|
||||
p.recordFileResult(filePath, 0, "", false, false, "", err)
|
||||
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Use the resource monitor-aware processing with metrics tracking
|
||||
fileSize, format, success, processErr := p.processFileWithMetrics(fileCtx, filePath, writeCh, absRoot)
|
||||
|
||||
// Record the processing result (skipped=false, skipReason="" since processFileWithMetrics never skips)
|
||||
p.recordFileResult(filePath, fileSize, format, success, false, "", processErr)
|
||||
|
||||
// Update progress bar with metrics
|
||||
if p.ui != nil {
|
||||
p.ui.UpdateProgress(1)
|
||||
}
|
||||
|
||||
// Show real-time stats in verbose mode
|
||||
if p.flags.Verbose && p.metricsCollector != nil {
|
||||
currentMetrics := p.metricsCollector.CurrentMetrics()
|
||||
if currentMetrics.ProcessedFiles%10 == 0 && p.metricsReporter != nil {
|
||||
logger := shared.GetLogger()
|
||||
logger.Info(p.metricsReporter.ReportProgress())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendFiles sends files to the worker channels with back-pressure handling.
|
||||
func (p *Processor) sendFiles(ctx context.Context, files []string, fileCh chan string) error {
|
||||
defer close(fileCh)
|
||||
|
||||
for _, fp := range files {
|
||||
// Check if we should apply back-pressure
|
||||
if p.backpressure.ShouldApplyBackpressure(ctx) {
|
||||
p.backpressure.ApplyBackpressure(ctx)
|
||||
}
|
||||
|
||||
// Wait for channel space if needed
|
||||
p.backpressure.WaitForChannelSpace(ctx, fileCh, nil)
|
||||
|
||||
if err := shared.CheckContextCancellation(ctx, shared.CLIMsgFileProcessingWorker); err != nil {
|
||||
return fmt.Errorf("context check failed: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case fileCh <- fp:
|
||||
case <-ctx.Done():
|
||||
if err := shared.CheckContextCancellation(ctx, shared.CLIMsgFileProcessingWorker); err != nil {
|
||||
return fmt.Errorf("context cancellation during channel send: %w", err)
|
||||
}
|
||||
|
||||
return errors.New("context canceled during channel send")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processFileWithMetrics wraps the file processing with detailed metrics collection.
|
||||
func (p *Processor) processFileWithMetrics(
|
||||
ctx context.Context,
|
||||
filePath string,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
absRoot string,
|
||||
) (fileSize int64, format string, success bool, err error) {
|
||||
// Get file info
|
||||
fileInfo, statErr := os.Stat(filePath)
|
||||
if statErr != nil {
|
||||
return 0, "", false, fmt.Errorf("getting file info for %s: %w", filePath, statErr)
|
||||
}
|
||||
|
||||
fileSize = fileInfo.Size()
|
||||
|
||||
// Detect format from file extension
|
||||
format = filepath.Ext(filePath)
|
||||
if format != "" && format[0] == '.' {
|
||||
format = format[1:] // Remove the dot
|
||||
}
|
||||
|
||||
// Use the existing resource monitor-aware processing
|
||||
err = fileproc.ProcessFileWithMonitor(ctx, filePath, writeCh, absRoot, p.resourceMonitor)
|
||||
|
||||
// Check if processing was successful
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fileSize, format, false, fmt.Errorf("file processing worker canceled: %w", ctx.Err())
|
||||
default:
|
||||
if err != nil {
|
||||
return fileSize, format, false, fmt.Errorf("processing file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
return fileSize, format, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// recordFileResult records the result of file processing in metrics.
|
||||
func (p *Processor) recordFileResult(
|
||||
filePath string,
|
||||
fileSize int64,
|
||||
format string,
|
||||
success bool,
|
||||
skipped bool,
|
||||
skipReason string,
|
||||
err error,
|
||||
) {
|
||||
if p.metricsCollector == nil {
|
||||
return // No metrics collector, skip recording
|
||||
}
|
||||
|
||||
result := metrics.FileProcessingResult{
|
||||
FilePath: filePath,
|
||||
FileSize: fileSize,
|
||||
Format: format,
|
||||
Success: success,
|
||||
Error: err,
|
||||
Skipped: skipped,
|
||||
SkipReason: skipReason,
|
||||
}
|
||||
|
||||
p.metricsCollector.RecordFileProcessed(result)
|
||||
}
|
||||
|
||||
// waitForCompletion waits for all workers to complete.
|
||||
func (p *Processor) waitForCompletion(
|
||||
wg *sync.WaitGroup,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
writerDone chan struct{},
|
||||
) {
|
||||
wg.Wait()
|
||||
close(writeCh)
|
||||
<-writerDone
|
||||
}
|
||||
211
cli/ui.go
Normal file
211
cli/ui.go
Normal file
@@ -0,0 +1,211 @@
|
||||
// Package cli provides command-line interface functionality for gibidify.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// UIManager handles CLI user interface elements.
|
||||
type UIManager struct {
|
||||
enableColors bool
|
||||
enableProgress bool
|
||||
silentMode bool
|
||||
progressBar *progressbar.ProgressBar
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
// NewUIManager creates a new UI manager.
|
||||
func NewUIManager() *UIManager {
|
||||
return &UIManager{
|
||||
enableColors: isColorTerminal(),
|
||||
enableProgress: isInteractiveTerminal(),
|
||||
output: os.Stderr, // Progress and colors go to stderr
|
||||
}
|
||||
}
|
||||
|
||||
// SetColorOutput enables or disables colored output.
|
||||
func (ui *UIManager) SetColorOutput(enabled bool) {
|
||||
ui.enableColors = enabled
|
||||
color.NoColor = !enabled
|
||||
}
|
||||
|
||||
// SetProgressOutput enables or disables progress bars.
|
||||
func (ui *UIManager) SetProgressOutput(enabled bool) {
|
||||
ui.enableProgress = enabled
|
||||
}
|
||||
|
||||
// SetSilentMode enables or disables all UI output.
|
||||
func (ui *UIManager) SetSilentMode(silent bool) {
|
||||
ui.silentMode = silent
|
||||
if silent {
|
||||
ui.output = io.Discard
|
||||
} else {
|
||||
ui.output = os.Stderr
|
||||
}
|
||||
}
|
||||
|
||||
// StartProgress initializes a progress bar for file processing.
|
||||
func (ui *UIManager) StartProgress(total int, description string) {
|
||||
if !ui.enableProgress || total <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ui.progressBar = progressbar.NewOptions(
|
||||
total,
|
||||
progressbar.OptionSetWriter(ui.output),
|
||||
progressbar.OptionSetDescription(description),
|
||||
progressbar.OptionSetTheme(
|
||||
progressbar.Theme{
|
||||
Saucer: color.GreenString(shared.UIProgressBarChar),
|
||||
SaucerHead: color.GreenString(shared.UIProgressBarChar),
|
||||
SaucerPadding: " ",
|
||||
BarStart: "[",
|
||||
BarEnd: "]",
|
||||
},
|
||||
),
|
||||
progressbar.OptionShowCount(),
|
||||
progressbar.OptionShowIts(),
|
||||
progressbar.OptionSetWidth(40),
|
||||
progressbar.OptionThrottle(100*time.Millisecond),
|
||||
progressbar.OptionOnCompletion(
|
||||
func() {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
_, _ = fmt.Fprint(ui.output, "\n")
|
||||
},
|
||||
),
|
||||
progressbar.OptionSetRenderBlankState(true),
|
||||
)
|
||||
}
|
||||
|
||||
// UpdateProgress increments the progress bar.
|
||||
func (ui *UIManager) UpdateProgress(increment int) {
|
||||
if ui.progressBar != nil {
|
||||
_ = ui.progressBar.Add(increment)
|
||||
}
|
||||
}
|
||||
|
||||
// FinishProgress completes the progress bar.
|
||||
func (ui *UIManager) FinishProgress() {
|
||||
if ui.progressBar != nil {
|
||||
_ = ui.progressBar.Finish()
|
||||
ui.progressBar = nil
|
||||
}
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message in green.
|
||||
func (ui *UIManager) PrintSuccess(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Green("✓ "+format, args...)
|
||||
} else {
|
||||
ui.printf("✓ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintError prints an error message in red.
|
||||
func (ui *UIManager) PrintError(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Red("✗ "+format, args...)
|
||||
} else {
|
||||
ui.printf("✗ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintWarning prints a warning message in yellow.
|
||||
func (ui *UIManager) PrintWarning(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
color.Yellow("⚠ "+format, args...)
|
||||
} else {
|
||||
ui.printf("⚠ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintInfo prints an info message in blue.
|
||||
func (ui *UIManager) PrintInfo(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
color.Blue("ℹ "+format, args...)
|
||||
} else {
|
||||
ui.printf("ℹ "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintHeader prints a header message in bold.
|
||||
func (ui *UIManager) PrintHeader(format string, args ...any) {
|
||||
if ui.silentMode {
|
||||
return
|
||||
}
|
||||
if ui.enableColors {
|
||||
//nolint:errcheck // UI output, errors don't affect processing
|
||||
_, _ = color.New(color.Bold).Fprintf(ui.output, format+"\n", args...)
|
||||
} else {
|
||||
ui.printf(format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// isColorTerminal checks if the terminal supports colors.
|
||||
func isColorTerminal() bool {
|
||||
// Check common environment variables
|
||||
term := os.Getenv("TERM")
|
||||
if term == "" || term == "dumb" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for CI environments that typically don't support colors
|
||||
if os.Getenv("CI") != "" {
|
||||
// GitHub Actions supports colors
|
||||
if os.Getenv("GITHUB_ACTIONS") == shared.LiteralTrue {
|
||||
return true
|
||||
}
|
||||
// Most other CI systems don't
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if NO_COLOR is set (https://no-color.org/)
|
||||
if os.Getenv("NO_COLOR") != "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if FORCE_COLOR is set
|
||||
if os.Getenv("FORCE_COLOR") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Default to true for interactive terminals
|
||||
return isInteractiveTerminal()
|
||||
}
|
||||
|
||||
// isInteractiveTerminal checks if we're running in an interactive terminal.
|
||||
func isInteractiveTerminal() bool {
|
||||
// Check if stderr is a terminal (where we output progress/colors)
|
||||
fileInfo, err := os.Stderr.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return (fileInfo.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
// printf is a helper that ignores printf errors (for UI output).
|
||||
func (ui *UIManager) printf(format string, args ...any) {
|
||||
_, _ = fmt.Fprintf(ui.output, format, args...)
|
||||
}
|
||||
531
cli/ui_test.go
Normal file
531
cli/ui_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
func TestNewUIManager(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
if ui == nil {
|
||||
t.Error("NewUIManager() returned nil")
|
||||
|
||||
return
|
||||
}
|
||||
if ui.output == nil {
|
||||
t.Error("NewUIManager() did not set output")
|
||||
|
||||
return
|
||||
}
|
||||
if ui.output != os.Stderr {
|
||||
t.Error("NewUIManager() should default output to os.Stderr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerSetColorOutput(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Test enabling colors
|
||||
ui.SetColorOutput(true)
|
||||
if !ui.enableColors {
|
||||
t.Error("SetColorOutput(true) did not enable colors")
|
||||
}
|
||||
|
||||
// Test disabling colors
|
||||
ui.SetColorOutput(false)
|
||||
if ui.enableColors {
|
||||
t.Error("SetColorOutput(false) did not disable colors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerSetProgressOutput(t *testing.T) {
|
||||
ui := NewUIManager()
|
||||
|
||||
// Test enabling progress
|
||||
ui.SetProgressOutput(true)
|
||||
if !ui.enableProgress {
|
||||
t.Error("SetProgressOutput(true) did not enable progress")
|
||||
}
|
||||
|
||||
// Test disabling progress
|
||||
ui.SetProgressOutput(false)
|
||||
if ui.enableProgress {
|
||||
t.Error("SetProgressOutput(false) did not disable progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerStartProgress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
total int
|
||||
description string
|
||||
enabled bool
|
||||
expectBar bool
|
||||
}{
|
||||
{
|
||||
name: "valid progress with enabled progress",
|
||||
total: 10,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: true,
|
||||
},
|
||||
{
|
||||
name: "disabled progress should not create bar",
|
||||
total: 10,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: false,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "zero total should not create bar",
|
||||
total: 0,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
{
|
||||
name: "negative total should not create bar",
|
||||
total: -1,
|
||||
description: shared.TestProgressMessage,
|
||||
enabled: true,
|
||||
expectBar: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(tt.enabled)
|
||||
|
||||
ui.StartProgress(tt.total, tt.description)
|
||||
|
||||
if tt.expectBar && ui.progressBar == nil {
|
||||
t.Error("StartProgress() should have created progress bar but didn't")
|
||||
}
|
||||
if !tt.expectBar && ui.progressBar != nil {
|
||||
t.Error("StartProgress() should not have created progress bar but did")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIManagerUpdateProgress(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(true)
|
||||
|
||||
// Test with no progress bar (should not panic)
|
||||
ui.UpdateProgress(1)
|
||||
|
||||
// Test with progress bar
|
||||
ui.StartProgress(10, "Test progress")
|
||||
if ui.progressBar == nil {
|
||||
t.Fatal("StartProgress() did not create progress bar")
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
ui.UpdateProgress(1)
|
||||
ui.UpdateProgress(5)
|
||||
}
|
||||
|
||||
func TestUIManagerFinishProgress(t *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetProgressOutput(true)
|
||||
|
||||
// Test with no progress bar (should not panic)
|
||||
ui.FinishProgress()
|
||||
|
||||
// Test with progress bar
|
||||
ui.StartProgress(10, "Test progress")
|
||||
if ui.progressBar == nil {
|
||||
t.Fatal("StartProgress() did not create progress bar")
|
||||
}
|
||||
|
||||
ui.FinishProgress()
|
||||
if ui.progressBar != nil {
|
||||
t.Error("FinishProgress() should have cleared progress bar")
|
||||
}
|
||||
}
|
||||
|
||||
// testPrintMethod is a helper function to test UI print methods without duplication.
|
||||
type printMethodTest struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []any
|
||||
expectedText string
|
||||
}
|
||||
|
||||
func testPrintMethod(
|
||||
t *testing.T,
|
||||
methodName string,
|
||||
printFunc func(*UIManager, string, ...any),
|
||||
tests []printMethodTest,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
ui.SetColorOutput(tt.enableColors)
|
||||
|
||||
printFunc(ui, tt.format, tt.args...)
|
||||
|
||||
if !tt.enableColors {
|
||||
outputStr := output.String()
|
||||
if !strings.Contains(outputStr, tt.expectedText) {
|
||||
t.Errorf("%s() output %q should contain %q", methodName, outputStr, tt.expectedText)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Test color method separately (doesn't capture output but shouldn't panic)
|
||||
t.Run(
|
||||
methodName+" with colors should not panic", func(_ *testing.T) {
|
||||
ui, _ := createTestUI() //nolint:errcheck // Test helper output buffer not used in this test
|
||||
ui.SetColorOutput(true)
|
||||
// Should not panic
|
||||
printFunc(ui, "Test message")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintSuccess(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "success without colors",
|
||||
enableColors: false,
|
||||
format: "Operation completed successfully",
|
||||
args: []any{},
|
||||
expectedText: "✓ Operation completed successfully",
|
||||
},
|
||||
{
|
||||
name: "success with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processed %d files in %s",
|
||||
args: []any{5, "project"},
|
||||
expectedText: "✓ Processed 5 files in project",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintSuccess", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintSuccess(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintError(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "error without colors",
|
||||
enableColors: false,
|
||||
format: "Operation failed",
|
||||
args: []any{},
|
||||
expectedText: "✗ Operation failed",
|
||||
},
|
||||
{
|
||||
name: "error with args without colors",
|
||||
enableColors: false,
|
||||
format: "Failed to process %d files",
|
||||
args: []any{3},
|
||||
expectedText: "✗ Failed to process 3 files",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintError", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintError(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintWarning(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "warning without colors",
|
||||
enableColors: false,
|
||||
format: "This is a warning",
|
||||
args: []any{},
|
||||
expectedText: "⚠ This is a warning",
|
||||
},
|
||||
{
|
||||
name: "warning with args without colors",
|
||||
enableColors: false,
|
||||
format: "Found %d potential issues",
|
||||
args: []any{2},
|
||||
expectedText: "⚠ Found 2 potential issues",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintWarning", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintWarning(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintInfo(t *testing.T) {
|
||||
tests := []printMethodTest{
|
||||
{
|
||||
name: "info without colors",
|
||||
enableColors: false,
|
||||
format: "Information message",
|
||||
args: []any{},
|
||||
expectedText: "ℹ Information message",
|
||||
},
|
||||
{
|
||||
name: "info with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processing file %s",
|
||||
args: []any{"example.go"},
|
||||
expectedText: "ℹ Processing file example.go",
|
||||
},
|
||||
}
|
||||
|
||||
testPrintMethod(
|
||||
t, "PrintInfo", func(ui *UIManager, format string, args ...any) {
|
||||
ui.PrintInfo(format, args...)
|
||||
}, tests,
|
||||
)
|
||||
}
|
||||
|
||||
func TestUIManagerPrintHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enableColors bool
|
||||
format string
|
||||
args []any
|
||||
expectedText string
|
||||
}{
|
||||
{
|
||||
name: "header without colors",
|
||||
enableColors: false,
|
||||
format: "Main Header",
|
||||
args: []any{},
|
||||
expectedText: "Main Header",
|
||||
},
|
||||
{
|
||||
name: "header with args without colors",
|
||||
enableColors: false,
|
||||
format: "Processing %s Module",
|
||||
args: []any{"CLI"},
|
||||
expectedText: "Processing CLI Module",
|
||||
},
|
||||
{
|
||||
name: "header with colors",
|
||||
enableColors: true,
|
||||
format: "Build Results",
|
||||
args: []any{},
|
||||
expectedText: "Build Results",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
ui.SetColorOutput(tt.enableColors)
|
||||
|
||||
ui.PrintHeader(tt.format, tt.args...)
|
||||
|
||||
outputStr := output.String()
|
||||
if !strings.Contains(outputStr, tt.expectedText) {
|
||||
t.Errorf("PrintHeader() output %q should contain %q", outputStr, tt.expectedText)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// colorTerminalTestCase represents a test case for color terminal detection.
|
||||
type colorTerminalTestCase struct {
|
||||
name string
|
||||
term string
|
||||
ci string
|
||||
githubActions string
|
||||
noColor string
|
||||
forceColor string
|
||||
expected bool
|
||||
}
|
||||
|
||||
// clearColorTerminalEnvVars clears all environment variables used for terminal color detection.
|
||||
func clearColorTerminalEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
envVars := []string{"TERM", "CI", "GITHUB_ACTIONS", "NO_COLOR", "FORCE_COLOR"}
|
||||
for _, envVar := range envVars {
|
||||
if err := os.Unsetenv(envVar); err != nil {
|
||||
t.Logf("Failed to unset %s: %v", envVar, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setColorTerminalTestEnv sets up environment variables for a test case.
|
||||
func setColorTerminalTestEnv(t *testing.T, testCase colorTerminalTestCase) {
|
||||
t.Helper()
|
||||
|
||||
envSettings := map[string]string{
|
||||
"TERM": testCase.term,
|
||||
"CI": testCase.ci,
|
||||
"GITHUB_ACTIONS": testCase.githubActions,
|
||||
"NO_COLOR": testCase.noColor,
|
||||
"FORCE_COLOR": testCase.forceColor,
|
||||
}
|
||||
|
||||
for key, value := range envSettings {
|
||||
if value != "" {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsColorTerminal(t *testing.T) {
|
||||
// Save original environment
|
||||
originalEnv := map[string]string{
|
||||
"TERM": os.Getenv("TERM"),
|
||||
"CI": os.Getenv("CI"),
|
||||
"GITHUB_ACTIONS": os.Getenv("GITHUB_ACTIONS"),
|
||||
"NO_COLOR": os.Getenv("NO_COLOR"),
|
||||
"FORCE_COLOR": os.Getenv("FORCE_COLOR"),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Restore original environment
|
||||
for key, value := range originalEnv {
|
||||
setEnvOrUnset(key, value)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []colorTerminalTestCase{
|
||||
{
|
||||
name: "dumb terminal",
|
||||
term: "dumb",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty term",
|
||||
term: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "github actions with CI",
|
||||
term: shared.TestTerminalXterm256,
|
||||
ci: "true",
|
||||
githubActions: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "CI without github actions",
|
||||
term: shared.TestTerminalXterm256,
|
||||
ci: "true",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "NO_COLOR set",
|
||||
term: shared.TestTerminalXterm256,
|
||||
noColor: "1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "FORCE_COLOR set",
|
||||
term: shared.TestTerminalXterm256,
|
||||
forceColor: "1",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
clearColorTerminalEnvVars(t)
|
||||
setColorTerminalTestEnv(t, tt)
|
||||
|
||||
result := isColorTerminal()
|
||||
if result != tt.expected {
|
||||
t.Errorf("isColorTerminal() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInteractiveTerminal(_ *testing.T) {
|
||||
// This test is limited because we can't easily mock os.Stderr.Stat()
|
||||
// but we can at least verify it doesn't panic and returns a boolean
|
||||
result := isInteractiveTerminal()
|
||||
|
||||
// Result should be a boolean (true or false, both are valid)
|
||||
// result is already a boolean, so this check is always satisfied
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestUIManagerprintf(t *testing.T) {
|
||||
ui, output := createTestUI()
|
||||
|
||||
ui.printf("Hello %s", "world")
|
||||
|
||||
expected := "Hello world"
|
||||
if output.String() != expected {
|
||||
t.Errorf("printf() = %q, want %q", output.String(), expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to set environment variable or unset if empty.
|
||||
func setEnvOrUnset(key, value string) {
|
||||
if value == "" {
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
// In tests, environment variable errors are not critical,
|
||||
// but we should still handle them to avoid linting issues
|
||||
_ = err // explicitly ignore error
|
||||
}
|
||||
} else {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
// In tests, environment variable errors are not critical,
|
||||
// but we should still handle them to avoid linting issues
|
||||
_ = err // explicitly ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for UI workflow.
|
||||
func TestUIManagerIntegration(t *testing.T) {
|
||||
ui, output := createTestUI() //nolint:errcheck // Test helper, output buffer is used
|
||||
ui.SetColorOutput(false) // Disable colors for consistent output
|
||||
ui.SetProgressOutput(false) // Disable progress for testing
|
||||
|
||||
// Simulate a complete UI workflow
|
||||
ui.PrintHeader("Starting Processing")
|
||||
ui.PrintInfo("Initializing system")
|
||||
ui.StartProgress(3, shared.TestProgressMessage)
|
||||
ui.UpdateProgress(1)
|
||||
ui.PrintInfo("Processing file 1")
|
||||
ui.UpdateProgress(1)
|
||||
ui.PrintWarning("Skipping invalid file")
|
||||
ui.UpdateProgress(1)
|
||||
ui.FinishProgress()
|
||||
ui.PrintSuccess("Processing completed successfully")
|
||||
|
||||
outputStr := output.String()
|
||||
|
||||
expectedStrings := []string{
|
||||
"Starting Processing",
|
||||
"ℹ Initializing system",
|
||||
"ℹ Processing file 1",
|
||||
"⚠ Skipping invalid file",
|
||||
"✓ Processing completed successfully",
|
||||
}
|
||||
|
||||
for _, expected := range expectedStrings {
|
||||
if !strings.Contains(outputStr, expected) {
|
||||
t.Errorf("Integration test output missing expected string: %q\nFull output:\n%s", expected, outputStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
202
cmd/benchmark/main.go
Normal file
202
cmd/benchmark/main.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Package main provides a CLI for running gibidify benchmarks.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/benchmark"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
var (
|
||||
sourceDir = flag.String(
|
||||
shared.CLIArgSource, "", "Source directory to benchmark (uses temp files if empty)",
|
||||
)
|
||||
benchmarkType = flag.String(
|
||||
"type", shared.CLIArgAll, "Benchmark type: all, collection, processing, concurrency, format",
|
||||
)
|
||||
format = flag.String(
|
||||
shared.CLIArgFormat, shared.FormatJSON, "Output format for processing benchmarks",
|
||||
)
|
||||
concurrency = flag.Int(
|
||||
shared.CLIArgConcurrency, runtime.NumCPU(), "Concurrency level for processing benchmarks",
|
||||
)
|
||||
concurrencyList = flag.String(
|
||||
"concurrency-list", shared.TestConcurrencyList, "Comma-separated list of concurrency levels",
|
||||
)
|
||||
formatList = flag.String(
|
||||
"format-list", shared.TestFormatList, "Comma-separated list of formats",
|
||||
)
|
||||
numFiles = flag.Int("files", shared.BenchmarkDefaultFileCount, "Number of files to create for benchmarks")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if err := runBenchmarks(); err != nil {
|
||||
//goland:noinspection GoUnhandledErrorResult
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Benchmark failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runBenchmarks() error {
|
||||
//nolint:errcheck // Benchmark informational output, errors don't affect benchmark results
|
||||
_, _ = fmt.Println("Running gibidify benchmarks...")
|
||||
//nolint:errcheck // Benchmark informational output, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("Source: %s\n", getSourceDescription())
|
||||
//nolint:errcheck // Benchmark informational output, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("Type: %s\n", *benchmarkType)
|
||||
//nolint:errcheck // Benchmark informational output, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("CPU cores: %d\n", runtime.NumCPU())
|
||||
//nolint:errcheck // Benchmark informational output, errors don't affect benchmark results
|
||||
_, _ = fmt.Println()
|
||||
|
||||
switch *benchmarkType {
|
||||
case shared.CLIArgAll:
|
||||
if err := benchmark.RunAllBenchmarks(*sourceDir); err != nil {
|
||||
return fmt.Errorf("benchmark failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
case "collection":
|
||||
return runCollectionBenchmark()
|
||||
case "processing":
|
||||
return runProcessingBenchmark()
|
||||
case "concurrency":
|
||||
return runConcurrencyBenchmark()
|
||||
case "format":
|
||||
return runFormatBenchmark()
|
||||
default:
|
||||
return shared.NewValidationError(shared.CodeValidationFormat, "invalid benchmark type: "+*benchmarkType)
|
||||
}
|
||||
}
|
||||
|
||||
func runCollectionBenchmark() error {
|
||||
//nolint:errcheck // Benchmark status message, errors don't affect benchmark results
|
||||
_, _ = fmt.Println(shared.BenchmarkMsgRunningCollection)
|
||||
result, err := benchmark.FileCollectionBenchmark(*sourceDir, *numFiles)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgFileCollectionFailed,
|
||||
)
|
||||
}
|
||||
benchmark.PrintResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runProcessingBenchmark() error {
|
||||
//nolint:errcheck // Benchmark status message, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("Running file processing benchmark (format: %s, concurrency: %d)...\n", *format, *concurrency)
|
||||
result, err := benchmark.FileProcessingBenchmark(*sourceDir, *format, *concurrency)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
"file processing benchmark failed",
|
||||
)
|
||||
}
|
||||
benchmark.PrintResult(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConcurrencyBenchmark() error {
|
||||
concurrencyLevels, err := parseConcurrencyList(*concurrencyList)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err, shared.ErrorTypeValidation, shared.CodeValidationFormat, "invalid concurrency list")
|
||||
}
|
||||
|
||||
//nolint:errcheck // Benchmark status message, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("Running concurrency benchmark (format: %s, levels: %v)...\n", *format, concurrencyLevels)
|
||||
suite, err := benchmark.ConcurrencyBenchmark(*sourceDir, *format, concurrencyLevels)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingCollection,
|
||||
shared.BenchmarkMsgConcurrencyFailed,
|
||||
)
|
||||
}
|
||||
benchmark.PrintSuite(suite)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runFormatBenchmark() error {
|
||||
formats := parseFormatList(*formatList)
|
||||
//nolint:errcheck // Benchmark status message, errors don't affect benchmark results
|
||||
_, _ = fmt.Printf("Running format benchmark (formats: %v)...\n", formats)
|
||||
suite, err := benchmark.FormatBenchmark(*sourceDir, formats)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err, shared.ErrorTypeProcessing, shared.CodeProcessingCollection, shared.BenchmarkMsgFormatFailed,
|
||||
)
|
||||
}
|
||||
benchmark.PrintSuite(suite)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSourceDescription() string {
|
||||
if *sourceDir == "" {
|
||||
return fmt.Sprintf("temporary files (%d files)", *numFiles)
|
||||
}
|
||||
|
||||
return *sourceDir
|
||||
}
|
||||
|
||||
func parseConcurrencyList(list string) ([]int, error) {
|
||||
parts := strings.Split(list, ",")
|
||||
levels := make([]int, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
var level int
|
||||
if _, err := fmt.Sscanf(part, "%d", &level); err != nil {
|
||||
return nil, shared.WrapErrorf(
|
||||
err,
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationFormat,
|
||||
"invalid concurrency level: %s",
|
||||
part,
|
||||
)
|
||||
}
|
||||
if level <= 0 {
|
||||
return nil, shared.NewValidationError(
|
||||
shared.CodeValidationFormat, "concurrency level must be positive: "+part,
|
||||
)
|
||||
}
|
||||
levels = append(levels, level)
|
||||
}
|
||||
|
||||
if len(levels) == 0 {
|
||||
return nil, shared.NewValidationError(shared.CodeValidationFormat, "no valid concurrency levels found")
|
||||
}
|
||||
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
func parseFormatList(list string) []string {
|
||||
parts := strings.Split(list, ",")
|
||||
formats := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
formats = append(formats, part)
|
||||
}
|
||||
}
|
||||
|
||||
return formats
|
||||
}
|
||||
751
cmd/benchmark/main_test.go
Normal file
751
cmd/benchmark/main_test.go
Normal file
@@ -0,0 +1,751 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
// Test constants to avoid goconst linting issues.
|
||||
const (
|
||||
testJSON = "json"
|
||||
testMarkdown = "markdown"
|
||||
testConcurrency = "1,2"
|
||||
testAll = "all"
|
||||
testCollection = "collection"
|
||||
testConcurrencyT = "concurrency"
|
||||
testNonExistent = "/nonexistent/path/that/should/not/exist"
|
||||
testFile1 = "test1.txt"
|
||||
testFile2 = "test2.txt"
|
||||
testContent1 = "content1"
|
||||
testContent2 = "content2"
|
||||
)
|
||||
|
||||
func TestParseConcurrencyList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []int
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid single value",
|
||||
input: "4",
|
||||
want: []int{4},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid multiple values",
|
||||
input: shared.TestConcurrencyList,
|
||||
want: []int{1, 2, 4, 8},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid with whitespace",
|
||||
input: " 1 , 2 , 4 , 8 ",
|
||||
want: []int{1, 2, 4, 8},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid single large value",
|
||||
input: "16",
|
||||
want: []int{16},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errContains: shared.TestMsgInvalidConcurrencyLevel,
|
||||
},
|
||||
{
|
||||
name: "invalid number",
|
||||
input: "1,abc,4",
|
||||
wantErr: true,
|
||||
errContains: shared.TestMsgInvalidConcurrencyLevel,
|
||||
},
|
||||
{
|
||||
name: "zero value",
|
||||
input: "1,0,4",
|
||||
wantErr: true,
|
||||
errContains: "concurrency level must be positive",
|
||||
},
|
||||
{
|
||||
name: "negative value",
|
||||
input: "1,-2,4",
|
||||
wantErr: true,
|
||||
errContains: "concurrency level must be positive",
|
||||
},
|
||||
{
|
||||
name: "only whitespace",
|
||||
input: " , , ",
|
||||
wantErr: true,
|
||||
errContains: shared.TestMsgInvalidConcurrencyLevel,
|
||||
},
|
||||
{
|
||||
name: "large value list",
|
||||
input: "1,2,4,8,16",
|
||||
want: []int{1, 2, 4, 8, 16},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseConcurrencyList(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
testutil.AssertExpectedError(t, err, "parseConcurrencyList")
|
||||
if tt.errContains != "" {
|
||||
testutil.AssertErrorContains(t, err, tt.errContains, "parseConcurrencyList")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
testutil.AssertNoError(t, err, "parseConcurrencyList")
|
||||
if !equalSlices(got, tt.want) {
|
||||
t.Errorf("parseConcurrencyList() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormatList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "single format",
|
||||
input: "json",
|
||||
want: []string{"json"},
|
||||
},
|
||||
{
|
||||
name: "multiple formats",
|
||||
input: shared.TestFormatList,
|
||||
want: []string{"json", "yaml", "markdown"},
|
||||
},
|
||||
{
|
||||
name: "formats with whitespace",
|
||||
input: " json , yaml , markdown ",
|
||||
want: []string{"json", "yaml", "markdown"},
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty parts",
|
||||
input: "json,,yaml",
|
||||
want: []string{"json", "yaml"},
|
||||
},
|
||||
{
|
||||
name: "only whitespace and commas",
|
||||
input: " , , ",
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single format with whitespace",
|
||||
input: " markdown ",
|
||||
want: []string{"markdown"},
|
||||
},
|
||||
{
|
||||
name: "duplicate formats",
|
||||
input: "json,json,yaml",
|
||||
want: []string{"json", "json", "yaml"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseFormatList(tt.input)
|
||||
if !equalSlices(got, tt.want) {
|
||||
t.Errorf("parseFormatList() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSourceDescription(t *testing.T) {
|
||||
// Save original flag values and reset after test
|
||||
origSourceDir := sourceDir
|
||||
origNumFiles := numFiles
|
||||
defer func() {
|
||||
sourceDir = origSourceDir
|
||||
numFiles = origNumFiles
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceDir string
|
||||
numFiles int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty source directory with default files",
|
||||
sourceDir: "",
|
||||
numFiles: 100,
|
||||
want: "temporary files (100 files)",
|
||||
},
|
||||
{
|
||||
name: "empty source directory with custom files",
|
||||
sourceDir: "",
|
||||
numFiles: 50,
|
||||
want: "temporary files (50 files)",
|
||||
},
|
||||
{
|
||||
name: "non-empty source directory",
|
||||
sourceDir: "/path/to/source",
|
||||
numFiles: 100,
|
||||
want: "/path/to/source",
|
||||
},
|
||||
{
|
||||
name: "current directory",
|
||||
sourceDir: ".",
|
||||
numFiles: 100,
|
||||
want: ".",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set flag pointers to test values
|
||||
*sourceDir = tt.sourceDir
|
||||
*numFiles = tt.numFiles
|
||||
|
||||
got := getSourceDescription()
|
||||
if got != tt.want {
|
||||
t.Errorf("getSourceDescription() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectionBenchmark(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origSourceDir := sourceDir
|
||||
origNumFiles := numFiles
|
||||
defer func() {
|
||||
sourceDir = origSourceDir
|
||||
numFiles = origNumFiles
|
||||
}()
|
||||
|
||||
t.Run("success with temp files", func(t *testing.T) {
|
||||
*sourceDir = ""
|
||||
*numFiles = 10
|
||||
|
||||
err := runCollectionBenchmark()
|
||||
testutil.AssertNoError(t, err, "runCollectionBenchmark with temp files")
|
||||
})
|
||||
|
||||
t.Run("success with real directory", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
{Name: testFile2, Content: testContent2},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*numFiles = 10
|
||||
|
||||
err := runCollectionBenchmark()
|
||||
testutil.AssertNoError(t, err, "runCollectionBenchmark with real directory")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunProcessingBenchmark(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origSourceDir := sourceDir
|
||||
origFormat := format
|
||||
origConcurrency := concurrency
|
||||
defer func() {
|
||||
sourceDir = origSourceDir
|
||||
format = origFormat
|
||||
concurrency = origConcurrency
|
||||
}()
|
||||
|
||||
t.Run("success with json format", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
{Name: testFile2, Content: testContent2},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*format = testJSON
|
||||
*concurrency = 2
|
||||
|
||||
err := runProcessingBenchmark()
|
||||
testutil.AssertNoError(t, err, "runProcessingBenchmark with json")
|
||||
})
|
||||
|
||||
t.Run("success with markdown format", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*format = testMarkdown
|
||||
*concurrency = 1
|
||||
|
||||
err := runProcessingBenchmark()
|
||||
testutil.AssertNoError(t, err, "runProcessingBenchmark with markdown")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunConcurrencyBenchmark(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origSourceDir := sourceDir
|
||||
origFormat := format
|
||||
origConcurrencyList := concurrencyList
|
||||
defer func() {
|
||||
sourceDir = origSourceDir
|
||||
format = origFormat
|
||||
concurrencyList = origConcurrencyList
|
||||
}()
|
||||
|
||||
t.Run("success with valid concurrency list", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*format = testJSON
|
||||
*concurrencyList = testConcurrency
|
||||
|
||||
err := runConcurrencyBenchmark()
|
||||
testutil.AssertNoError(t, err, "runConcurrencyBenchmark")
|
||||
})
|
||||
|
||||
t.Run("error with invalid concurrency list", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
*sourceDir = tempDir
|
||||
*format = testJSON
|
||||
*concurrencyList = "invalid"
|
||||
|
||||
err := runConcurrencyBenchmark()
|
||||
testutil.AssertExpectedError(t, err, "runConcurrencyBenchmark with invalid list")
|
||||
testutil.AssertErrorContains(t, err, "invalid concurrency list", "runConcurrencyBenchmark")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunFormatBenchmark(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origSourceDir := sourceDir
|
||||
origFormatList := formatList
|
||||
defer func() {
|
||||
sourceDir = origSourceDir
|
||||
formatList = origFormatList
|
||||
}()
|
||||
|
||||
t.Run("success with valid format list", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*formatList = "json,yaml"
|
||||
|
||||
err := runFormatBenchmark()
|
||||
testutil.AssertNoError(t, err, "runFormatBenchmark")
|
||||
})
|
||||
|
||||
t.Run("success with single format", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
*sourceDir = tempDir
|
||||
*formatList = testMarkdown
|
||||
|
||||
err := runFormatBenchmark()
|
||||
testutil.AssertNoError(t, err, "runFormatBenchmark with single format")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBenchmarks(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origBenchmarkType := benchmarkType
|
||||
origSourceDir := sourceDir
|
||||
origConcurrencyList := concurrencyList
|
||||
origFormatList := formatList
|
||||
defer func() {
|
||||
benchmarkType = origBenchmarkType
|
||||
sourceDir = origSourceDir
|
||||
concurrencyList = origConcurrencyList
|
||||
formatList = origFormatList
|
||||
}()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
benchmarkType string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "all benchmarks",
|
||||
benchmarkType: "all",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "collection benchmark",
|
||||
benchmarkType: "collection",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "processing benchmark",
|
||||
benchmarkType: "processing",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "concurrency benchmark",
|
||||
benchmarkType: "concurrency",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "format benchmark",
|
||||
benchmarkType: "format",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid benchmark type",
|
||||
benchmarkType: "invalid",
|
||||
wantErr: true,
|
||||
errContains: "invalid benchmark type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
*benchmarkType = tt.benchmarkType
|
||||
*sourceDir = tempDir
|
||||
*concurrencyList = testConcurrency
|
||||
*formatList = testMarkdown
|
||||
|
||||
err := runBenchmarks()
|
||||
|
||||
if tt.wantErr {
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks")
|
||||
if tt.errContains != "" {
|
||||
testutil.AssertErrorContains(t, err, tt.errContains, "runBenchmarks")
|
||||
}
|
||||
} else {
|
||||
testutil.AssertNoError(t, err, "runBenchmarks")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainFunction(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// We can't easily test main() directly due to os.Exit calls,
|
||||
// but we can test runBenchmarks() which contains the main logic
|
||||
tempDir := t.TempDir()
|
||||
testutil.CreateTestFiles(t, tempDir, []testutil.FileSpec{
|
||||
{Name: testFile1, Content: testContent1},
|
||||
})
|
||||
|
||||
// Save original flag values
|
||||
origBenchmarkType := benchmarkType
|
||||
origSourceDir := sourceDir
|
||||
defer func() {
|
||||
benchmarkType = origBenchmarkType
|
||||
sourceDir = origSourceDir
|
||||
}()
|
||||
|
||||
*benchmarkType = testCollection
|
||||
*sourceDir = tempDir
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertNoError(t, err, "runBenchmarks through main logic path")
|
||||
}
|
||||
|
||||
func TestFlagInitialization(t *testing.T) {
|
||||
// Test that flags are properly initialized with expected defaults
|
||||
resetFlags()
|
||||
|
||||
if *sourceDir != "" {
|
||||
t.Errorf("sourceDir default should be empty, got %v", *sourceDir)
|
||||
}
|
||||
if *benchmarkType != testAll {
|
||||
t.Errorf("benchmarkType default should be 'all', got %v", *benchmarkType)
|
||||
}
|
||||
if *format != testJSON {
|
||||
t.Errorf("format default should be 'json', got %v", *format)
|
||||
}
|
||||
if *concurrency != runtime.NumCPU() {
|
||||
t.Errorf("concurrency default should be %d, got %d", runtime.NumCPU(), *concurrency)
|
||||
}
|
||||
if *concurrencyList != shared.TestConcurrencyList {
|
||||
t.Errorf("concurrencyList default should be '%s', got %v", shared.TestConcurrencyList, *concurrencyList)
|
||||
}
|
||||
if *formatList != shared.TestFormatList {
|
||||
t.Errorf("formatList default should be '%s', got %v", shared.TestFormatList, *formatList)
|
||||
}
|
||||
if *numFiles != 100 {
|
||||
t.Errorf("numFiles default should be 100, got %d", *numFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorPropagation(t *testing.T) {
|
||||
restore := testutil.SuppressLogs(t)
|
||||
defer restore()
|
||||
|
||||
// Save original flag values
|
||||
origBenchmarkType := benchmarkType
|
||||
origSourceDir := sourceDir
|
||||
origConcurrencyList := concurrencyList
|
||||
defer func() {
|
||||
benchmarkType = origBenchmarkType
|
||||
sourceDir = origSourceDir
|
||||
concurrencyList = origConcurrencyList
|
||||
}()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("error from concurrency benchmark propagates", func(t *testing.T) {
|
||||
*benchmarkType = testConcurrencyT
|
||||
*sourceDir = tempDir
|
||||
*concurrencyList = "invalid,list"
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks with invalid concurrency")
|
||||
testutil.AssertErrorContains(t, err, "invalid concurrency list", "runBenchmarks error propagation")
|
||||
})
|
||||
|
||||
t.Run("validation error contains proper error type", func(t *testing.T) {
|
||||
*benchmarkType = "invalid-type"
|
||||
*sourceDir = tempDir
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks with invalid type")
|
||||
|
||||
var validationErr *shared.StructuredError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Errorf("Expected StructuredError, got %T", err)
|
||||
} else if validationErr.Code != shared.CodeValidationFormat {
|
||||
t.Errorf("Expected validation format error code, got %v", validationErr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty levels array returns error", func(t *testing.T) {
|
||||
// Test the specific case where all parts are empty after trimming
|
||||
_, err := parseConcurrencyList(" , , ")
|
||||
testutil.AssertExpectedError(t, err, "parseConcurrencyList with all empty parts")
|
||||
testutil.AssertErrorContains(t, err, shared.TestMsgInvalidConcurrencyLevel, "parseConcurrencyList empty levels")
|
||||
})
|
||||
|
||||
t.Run("single empty part returns error", func(t *testing.T) {
|
||||
// Test case that should never reach the "no valid levels found" condition
|
||||
_, err := parseConcurrencyList(" ")
|
||||
testutil.AssertExpectedError(t, err, "parseConcurrencyList with single empty part")
|
||||
testutil.AssertErrorContains(
|
||||
t, err, shared.TestMsgInvalidConcurrencyLevel, "parseConcurrencyList single empty part",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("benchmark function error paths", func(t *testing.T) {
|
||||
// Test with non-existent source directory to trigger error paths
|
||||
nonExistentDir := testNonExistent
|
||||
|
||||
*benchmarkType = testCollection
|
||||
*sourceDir = nonExistentDir
|
||||
|
||||
// This should fail as the benchmark package cannot access non-existent directories
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks with non-existent directory")
|
||||
testutil.AssertErrorContains(t, err, "file collection benchmark failed",
|
||||
"runBenchmarks error contains expected message")
|
||||
})
|
||||
|
||||
t.Run("processing benchmark error path", func(t *testing.T) {
|
||||
// Test error path for processing benchmark
|
||||
nonExistentDir := testNonExistent
|
||||
|
||||
*benchmarkType = "processing"
|
||||
*sourceDir = nonExistentDir
|
||||
*format = "json"
|
||||
*concurrency = 1
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks processing with non-existent directory")
|
||||
testutil.AssertErrorContains(t, err, "file processing benchmark failed", "runBenchmarks processing error")
|
||||
})
|
||||
|
||||
t.Run("concurrency benchmark error path", func(t *testing.T) {
|
||||
// Test error path for concurrency benchmark
|
||||
nonExistentDir := testNonExistent
|
||||
|
||||
*benchmarkType = testConcurrencyT
|
||||
*sourceDir = nonExistentDir
|
||||
*format = "json"
|
||||
*concurrencyList = "1,2"
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks concurrency with non-existent directory")
|
||||
testutil.AssertErrorContains(t, err, "concurrency benchmark failed", "runBenchmarks concurrency error")
|
||||
})
|
||||
|
||||
t.Run("format benchmark error path", func(t *testing.T) {
|
||||
// Test error path for format benchmark
|
||||
nonExistentDir := testNonExistent
|
||||
|
||||
*benchmarkType = "format"
|
||||
*sourceDir = nonExistentDir
|
||||
*formatList = "json,yaml"
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks format with non-existent directory")
|
||||
testutil.AssertErrorContains(t, err, "format benchmark failed", "runBenchmarks format error")
|
||||
})
|
||||
|
||||
t.Run("all benchmarks error path", func(t *testing.T) {
|
||||
// Test error path for all benchmarks
|
||||
nonExistentDir := testNonExistent
|
||||
|
||||
*benchmarkType = "all"
|
||||
*sourceDir = nonExistentDir
|
||||
|
||||
err := runBenchmarks()
|
||||
testutil.AssertExpectedError(t, err, "runBenchmarks all with non-existent directory")
|
||||
testutil.AssertErrorContains(t, err, "benchmark failed", "runBenchmarks all error")
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark functions
|
||||
|
||||
// BenchmarkParseConcurrencyList benchmarks the parsing of concurrency lists.
|
||||
func BenchmarkParseConcurrencyList(b *testing.B) {
|
||||
benchmarks := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "single value",
|
||||
input: "4",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
input: "1,2,4,8",
|
||||
},
|
||||
{
|
||||
name: "values with whitespace",
|
||||
input: " 1 , 2 , 4 , 8 , 16 ",
|
||||
},
|
||||
{
|
||||
name: "large list",
|
||||
input: "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16",
|
||||
},
|
||||
}
|
||||
|
||||
for _, bm := range benchmarks {
|
||||
b.Run(bm.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = parseConcurrencyList(bm.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkParseFormatList benchmarks the parsing of format lists.
|
||||
func BenchmarkParseFormatList(b *testing.B) {
|
||||
benchmarks := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "single format",
|
||||
input: "json",
|
||||
},
|
||||
{
|
||||
name: "multiple formats",
|
||||
input: shared.TestFormatList,
|
||||
},
|
||||
{
|
||||
name: "formats with whitespace",
|
||||
input: " json , yaml , markdown , xml , toml ",
|
||||
},
|
||||
{
|
||||
name: "large list",
|
||||
input: "json,yaml,markdown,xml,toml,csv,tsv,html,txt,log",
|
||||
},
|
||||
}
|
||||
|
||||
for _, bm := range benchmarks {
|
||||
b.Run(bm.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = parseFormatList(bm.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// equalSlices compares two slices for equality.
|
||||
func equalSlices[T comparable](a, b []T) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// resetFlags resets flag variables to their defaults for testing.
|
||||
func resetFlags() {
|
||||
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
||||
flag.CommandLine.SetOutput(io.Discard)
|
||||
// Reinitialize the flags
|
||||
sourceDir = flag.String("source", "", "Source directory to benchmark (uses temp files if empty)")
|
||||
benchmarkType = flag.String("type", "all", "Benchmark type: all, collection, processing, concurrency, format")
|
||||
format = flag.String("format", "json", "Output format for processing benchmarks")
|
||||
concurrency = flag.Int("concurrency", runtime.NumCPU(), "Concurrency level for processing benchmarks")
|
||||
concurrencyList = flag.String(
|
||||
"concurrency-list", shared.TestConcurrencyList, "Comma-separated list of concurrency levels",
|
||||
)
|
||||
formatList = flag.String("format-list", shared.TestFormatList, "Comma-separated list of formats")
|
||||
numFiles = flag.Int("files", 100, "Number of files to create for benchmarks")
|
||||
}
|
||||
333
config.example.yaml
Normal file
333
config.example.yaml
Normal file
@@ -0,0 +1,333 @@
|
||||
---
|
||||
# gibidify Configuration Example
|
||||
# =============================
|
||||
# This file demonstrates all available configuration options with their defaults
|
||||
# and validation ranges. Copy this file to one of the following locations:
|
||||
#
|
||||
# - $XDG_CONFIG_HOME/gibidify/config.yaml
|
||||
# - $HOME/.config/gibidify/config.yaml
|
||||
# - Current directory (if no gibidify.yaml output file exists)
|
||||
|
||||
# =============================================================================
|
||||
# BASIC FILE PROCESSING SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Maximum size for individual files in bytes
|
||||
# Default: 5242880 (5MB), Min: 1024 (1KB), Max: 104857600 (100MB)
|
||||
fileSizeLimit: 5242880
|
||||
|
||||
# Directories to ignore during file system traversal
|
||||
# These are sensible defaults for most projects
|
||||
ignoreDirectories:
|
||||
- vendor # Go vendor directory
|
||||
- node_modules # Node.js dependencies
|
||||
- .git # Git repository data
|
||||
- dist # Distribution/build output
|
||||
- build # Build artifacts
|
||||
- target # Maven/Rust build directory
|
||||
- bower_components # Bower dependencies
|
||||
- cache # Various cache directories
|
||||
- tmp # Temporary files
|
||||
- .next # Next.js build directory
|
||||
- .nuxt # Nuxt.js build directory
|
||||
- .vscode # VS Code settings
|
||||
- .idea # IntelliJ IDEA settings
|
||||
- __pycache__ # Python cache
|
||||
- .pytest_cache # Pytest cache
|
||||
|
||||
# Maximum number of worker goroutines for concurrent processing
|
||||
# Default: number of CPU cores, Min: 1, Max: 100
|
||||
# maxConcurrency: 8
|
||||
|
||||
# Supported output formats for validation
|
||||
# Default: ["json", "yaml", "markdown"]
|
||||
# supportedFormats:
|
||||
# - json
|
||||
# - yaml
|
||||
# - markdown
|
||||
|
||||
# File patterns to include (glob patterns)
|
||||
# Default: empty (all files), useful for filtering specific file types
|
||||
# filePatterns:
|
||||
# - "*.go"
|
||||
# - "*.py"
|
||||
# - "*.js"
|
||||
# - "*.ts"
|
||||
# - "*.java"
|
||||
# - "*.c"
|
||||
# - "*.cpp"
|
||||
|
||||
# =============================================================================
|
||||
# FILE TYPE DETECTION AND CUSTOMIZATION
|
||||
# =============================================================================
|
||||
|
||||
fileTypes:
|
||||
# Enable/disable file type detection entirely
|
||||
# Default: true
|
||||
enabled: true
|
||||
|
||||
# Add custom image extensions (beyond built-in: .png, .jpg, .jpeg, .gif, .svg, .ico, .bmp, .tiff, .webp)
|
||||
customImageExtensions:
|
||||
- .avif # AV1 Image File Format
|
||||
- .heic # High Efficiency Image Container
|
||||
- .jxl # JPEG XL
|
||||
- .webp # WebP (if not already included)
|
||||
|
||||
# Add custom binary extensions (beyond built-in: .exe, .dll, .so, .dylib, .a, .lib, .obj, .o)
|
||||
customBinaryExtensions:
|
||||
- .custom # Custom binary format
|
||||
- .proprietary # Proprietary format
|
||||
- .blob # Binary large object
|
||||
|
||||
# Add custom language mappings (extension -> language name)
|
||||
customLanguages:
|
||||
.zig: zig # Zig language
|
||||
.odin: odin # Odin language
|
||||
.v: vlang # V language
|
||||
.grain: grain # Grain language
|
||||
.gleam: gleam # Gleam language
|
||||
.roc: roc # Roc language
|
||||
.janet: janet # Janet language
|
||||
.fennel: fennel # Fennel language
|
||||
.wast: wast # WebAssembly text format
|
||||
.wat: wat # WebAssembly text format
|
||||
|
||||
# Disable specific default image extensions
|
||||
disabledImageExtensions:
|
||||
- .bmp # Disable bitmap support
|
||||
- .tiff # Disable TIFF support
|
||||
|
||||
# Disable specific default binary extensions
|
||||
disabledBinaryExtensions:
|
||||
- .exe # Don't treat executables as binary
|
||||
- .dll # Don't treat DLL files as binary
|
||||
|
||||
# Disable specific default language extensions
|
||||
disabledLanguageExtensions:
|
||||
- .bat # Don't detect batch files
|
||||
- .cmd # Don't detect command files
|
||||
|
||||
# =============================================================================
|
||||
# BACKPRESSURE AND MEMORY MANAGEMENT
|
||||
# =============================================================================
|
||||
|
||||
backpressure:
|
||||
# Enable backpressure management for memory optimization
|
||||
# Default: true
|
||||
enabled: true
|
||||
|
||||
# Maximum number of files to buffer in the processing pipeline
|
||||
# Default: 1000, helps prevent memory exhaustion with many small files
|
||||
maxPendingFiles: 1000
|
||||
|
||||
# Maximum number of write operations to buffer
|
||||
# Default: 100, controls write throughput vs memory usage
|
||||
maxPendingWrites: 100
|
||||
|
||||
# Soft memory usage limit in bytes before triggering backpressure
|
||||
# Default: 104857600 (100MB)
|
||||
maxMemoryUsage: 104857600
|
||||
|
||||
# Check memory usage every N files processed
|
||||
# Default: 1000, lower values = more frequent checks but higher overhead
|
||||
memoryCheckInterval: 1000
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE LIMITS AND SECURITY
|
||||
# =============================================================================
|
||||
|
||||
resourceLimits:
|
||||
# Enable resource limits for DoS protection
|
||||
# Default: true
|
||||
enabled: true
|
||||
|
||||
# Maximum number of files to process
|
||||
# Default: 10000, Min: 1, Max: 1000000
|
||||
maxFiles: 10000
|
||||
|
||||
# Maximum total size of all files combined in bytes
|
||||
# Default: 1073741824 (1GB), Min: 1048576 (1MB), Max: 107374182400 (100GB)
|
||||
maxTotalSize: 1073741824
|
||||
|
||||
# Timeout for processing individual files in seconds
|
||||
# Default: 30, Min: 1, Max: 300 (5 minutes)
|
||||
fileProcessingTimeoutSec: 30
|
||||
|
||||
# Overall timeout for the entire operation in seconds
|
||||
# Default: 3600 (1 hour), Min: 10, Max: 86400 (24 hours)
|
||||
overallTimeoutSec: 3600
|
||||
|
||||
# Maximum concurrent file reading operations
|
||||
# Default: 10, Min: 1, Max: 100
|
||||
maxConcurrentReads: 10
|
||||
|
||||
# Rate limit for file processing (files per second)
|
||||
# Default: 0 (disabled), Min: 0, Max: 10000
|
||||
rateLimitFilesPerSec: 0
|
||||
|
||||
# Hard memory limit in MB - terminates processing if exceeded
|
||||
# Default: 512, Min: 64, Max: 8192 (8GB)
|
||||
hardMemoryLimitMB: 512
|
||||
|
||||
# Enable graceful degradation under resource pressure
|
||||
# Default: true - reduces concurrency and buffers when under pressure
|
||||
enableGracefulDegradation: true
|
||||
|
||||
# Enable detailed resource monitoring and metrics
|
||||
# Default: true - tracks memory, timing, and processing statistics
|
||||
enableResourceMonitoring: true
|
||||
|
||||
# =============================================================================
|
||||
# OUTPUT FORMATTING AND TEMPLATES
|
||||
# =============================================================================
|
||||
|
||||
output:
|
||||
# Template selection: "" (default), "minimal", "detailed", "compact", or "custom"
|
||||
# Default: "" (uses built-in default template)
|
||||
template: ""
|
||||
|
||||
# Metadata inclusion options
|
||||
metadata:
|
||||
# Include processing statistics in output
|
||||
# Default: false
|
||||
includeStats: false
|
||||
|
||||
# Include timestamp when processing was done
|
||||
# Default: false
|
||||
includeTimestamp: false
|
||||
|
||||
# Include total number of files processed
|
||||
# Default: false
|
||||
includeFileCount: false
|
||||
|
||||
# Include source directory path
|
||||
# Default: false
|
||||
includeSourcePath: false
|
||||
|
||||
# Include detected file types summary
|
||||
# Default: false
|
||||
includeFileTypes: false
|
||||
|
||||
# Include processing time information
|
||||
# Default: false
|
||||
includeProcessingTime: false
|
||||
|
||||
# Include total size of processed files
|
||||
# Default: false
|
||||
includeTotalSize: false
|
||||
|
||||
# Include detailed processing metrics
|
||||
# Default: false
|
||||
includeMetrics: false
|
||||
|
||||
# Markdown-specific formatting options
|
||||
markdown:
|
||||
# Wrap file content in code blocks
|
||||
# Default: false
|
||||
useCodeBlocks: false
|
||||
|
||||
# Include language identifier in code blocks
|
||||
# Default: false
|
||||
includeLanguage: false
|
||||
|
||||
# Header level for file sections (1-6)
|
||||
# Default: 0 (uses template default, typically 2)
|
||||
headerLevel: 0
|
||||
|
||||
# Generate table of contents
|
||||
# Default: false
|
||||
tableOfContents: false
|
||||
|
||||
# Use collapsible sections for large files
|
||||
# Default: false
|
||||
useCollapsible: false
|
||||
|
||||
# Enable syntax highlighting hints
|
||||
# Default: false
|
||||
syntaxHighlighting: false
|
||||
|
||||
# Include line numbers in code blocks
|
||||
# Default: false
|
||||
lineNumbers: false
|
||||
|
||||
# Automatically fold files longer than maxLineLength
|
||||
# Default: false
|
||||
foldLongFiles: false
|
||||
|
||||
# Maximum line length before wrapping/folding
|
||||
# Default: 0 (no limit)
|
||||
maxLineLength: 0
|
||||
|
||||
# Custom CSS to include in markdown output
|
||||
# Default: "" (no custom CSS)
|
||||
customCSS: ""
|
||||
|
||||
# Custom template overrides (only used when template is "custom")
|
||||
custom:
|
||||
# Custom header template (supports Go template syntax)
|
||||
header: ""
|
||||
|
||||
# Custom footer template
|
||||
footer: ""
|
||||
|
||||
# Custom file header template (prepended to each file)
|
||||
fileHeader: ""
|
||||
|
||||
# Custom file footer template (appended to each file)
|
||||
fileFooter: ""
|
||||
|
||||
# Custom template variables accessible in all templates
|
||||
variables:
|
||||
# Example variables - customize as needed
|
||||
project_name: "My Project"
|
||||
author: "Developer Name"
|
||||
version: "1.0.0"
|
||||
description: "Generated code aggregation"
|
||||
# Add any custom key-value pairs here
|
||||
|
||||
# =============================================================================
|
||||
# EXAMPLES OF COMMON CONFIGURATIONS
|
||||
# =============================================================================
|
||||
|
||||
# Example 1: Minimal configuration for quick code review
|
||||
# fileSizeLimit: 1048576 # 1MB limit for faster processing
|
||||
# maxConcurrency: 4 # Lower concurrency for stability
|
||||
# ignoreDirectories: [".git", "node_modules", "vendor"]
|
||||
# output:
|
||||
# template: "minimal"
|
||||
# metadata:
|
||||
# includeStats: true
|
||||
|
||||
# Example 2: High-performance configuration for large codebases
|
||||
# fileSizeLimit: 10485760 # 10MB limit
|
||||
# maxConcurrency: 16 # High concurrency
|
||||
# backpressure:
|
||||
# maxPendingFiles: 5000 # Larger buffers
|
||||
# maxMemoryUsage: 536870912 # 512MB memory
|
||||
# resourceLimits:
|
||||
# maxFiles: 100000 # Process more files
|
||||
# maxTotalSize: 10737418240 # 10GB total size
|
||||
|
||||
# Example 3: Security-focused configuration
|
||||
# resourceLimits:
|
||||
# maxFiles: 1000 # Strict file limit
|
||||
# maxTotalSize: 104857600 # 100MB total limit
|
||||
# fileProcessingTimeoutSec: 10 # Short timeout
|
||||
# overallTimeoutSec: 300 # 5-minute overall limit
|
||||
# hardMemoryLimitMB: 256 # Lower memory limit
|
||||
# rateLimitFilesPerSec: 50 # Rate limiting enabled
|
||||
|
||||
# Example 4: Documentation-friendly output
|
||||
# output:
|
||||
# template: "detailed"
|
||||
# metadata:
|
||||
# includeStats: true
|
||||
# includeTimestamp: true
|
||||
# includeFileCount: true
|
||||
# includeSourcePath: true
|
||||
# markdown:
|
||||
# useCodeBlocks: true
|
||||
# includeLanguage: true
|
||||
# headerLevel: 2
|
||||
# tableOfContents: true
|
||||
# syntaxHighlighting: true
|
||||
@@ -1,53 +1,5 @@
|
||||
// Package config handles application configuration using Viper.
|
||||
// This file contains the main configuration orchestration logic.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// LoadConfig reads configuration from a YAML file.
|
||||
// It looks for config in the following order:
|
||||
// 1. $XDG_CONFIG_HOME/gibidify/config.yaml
|
||||
// 2. $HOME/.config/gibidify/config.yaml
|
||||
// 3. The current directory as fallback.
|
||||
func LoadConfig() {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
|
||||
viper.AddConfigPath(filepath.Join(xdgConfig, "gibidify"))
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
viper.AddConfigPath(filepath.Join(home, ".config", "gibidify"))
|
||||
}
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
logrus.Infof("Config file not found, using default values: %v", err)
|
||||
setDefaultConfig()
|
||||
} else {
|
||||
logrus.Infof("Using config file: %s", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
|
||||
// setDefaultConfig sets default configuration values.
|
||||
func setDefaultConfig() {
|
||||
viper.SetDefault("fileSizeLimit", 5242880) // 5 MB
|
||||
// Default ignored directories.
|
||||
viper.SetDefault("ignoreDirectories", []string{
|
||||
"vendor", "node_modules", ".git", "dist", "build", "target", "bower_components", "cache", "tmp",
|
||||
})
|
||||
}
|
||||
|
||||
// GetFileSizeLimit returns the file size limit from configuration.
|
||||
func GetFileSizeLimit() int64 {
|
||||
return viper.GetInt64("fileSizeLimit")
|
||||
}
|
||||
|
||||
// GetIgnoredDirectories returns the list of directories to ignore.
|
||||
func GetIgnoredDirectories() []string {
|
||||
return viper.GetStringSlice("ignoreDirectories")
|
||||
}
|
||||
// This file is now a minimal orchestration layer that delegates to the modular components.
|
||||
|
||||
226
config/config_filetype_test.go
Normal file
226
config/config_filetype_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// TestFileTypeRegistryDefaultValues tests default configuration values.
|
||||
func TestFileTypeRegistryDefaultValues(t *testing.T) {
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
|
||||
verifyDefaultValues(t)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistrySetGet tests configuration setting and getting.
|
||||
func TestFileTypeRegistrySetGet(t *testing.T) {
|
||||
viper.Reset()
|
||||
|
||||
// Set test values
|
||||
setTestConfiguration()
|
||||
|
||||
// Test getter functions
|
||||
verifyTestConfiguration(t)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryValidationSuccess tests successful validation.
|
||||
func TestFileTypeRegistryValidationSuccess(t *testing.T) {
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
|
||||
// Set valid configuration
|
||||
setValidConfiguration()
|
||||
|
||||
err := ValidateConfig()
|
||||
if err != nil {
|
||||
t.Errorf("Expected validation to pass with valid config, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryValidationFailure tests validation failures.
|
||||
func TestFileTypeRegistryValidationFailure(t *testing.T) {
|
||||
// Test invalid custom image extensions
|
||||
testInvalidImageExtensions(t)
|
||||
|
||||
// Test invalid custom binary extensions
|
||||
testInvalidBinaryExtensions(t)
|
||||
|
||||
// Test invalid custom languages
|
||||
testInvalidCustomLanguages(t)
|
||||
}
|
||||
|
||||
// verifyDefaultValues verifies that default values are correct.
|
||||
func verifyDefaultValues(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if !FileTypesEnabled() {
|
||||
t.Error("Expected file types to be enabled by default")
|
||||
}
|
||||
|
||||
verifyEmptySlice(t, CustomImageExtensions(), "custom image extensions")
|
||||
verifyEmptySlice(t, CustomBinaryExtensions(), "custom binary extensions")
|
||||
verifyEmptyMap(t, CustomLanguages(), "custom languages")
|
||||
verifyEmptySlice(t, DisabledImageExtensions(), "disabled image extensions")
|
||||
verifyEmptySlice(t, DisabledBinaryExtensions(), "disabled binary extensions")
|
||||
verifyEmptySlice(t, DisabledLanguageExtensions(), "disabled language extensions")
|
||||
}
|
||||
|
||||
// setTestConfiguration sets test configuration values.
|
||||
func setTestConfiguration() {
|
||||
viper.Set("fileTypes.enabled", false)
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomImageExtensions, []string{".webp", ".avif"})
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomBinaryExtensions, []string{shared.TestExtensionCustom, ".mybin"})
|
||||
viper.Set(
|
||||
shared.ConfigKeyFileTypesCustomLanguages, map[string]string{
|
||||
".zig": "zig",
|
||||
".v": "vlang",
|
||||
},
|
||||
)
|
||||
viper.Set("fileTypes.disabledImageExtensions", []string{".gif", ".bmp"})
|
||||
viper.Set("fileTypes.disabledBinaryExtensions", []string{".exe", ".dll"})
|
||||
viper.Set("fileTypes.disabledLanguageExtensions", []string{".rb", ".pl"})
|
||||
}
|
||||
|
||||
// verifyTestConfiguration verifies that test configuration is retrieved correctly.
|
||||
func verifyTestConfiguration(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if FileTypesEnabled() {
|
||||
t.Error("Expected file types to be disabled")
|
||||
}
|
||||
|
||||
verifyStringSlice(t, CustomImageExtensions(), []string{".webp", ".avif"}, "custom image extensions")
|
||||
verifyStringSlice(t, CustomBinaryExtensions(), []string{".custom", ".mybin"}, "custom binary extensions")
|
||||
|
||||
expectedLangs := map[string]string{
|
||||
".zig": "zig",
|
||||
".v": "vlang",
|
||||
}
|
||||
verifyStringMap(t, CustomLanguages(), expectedLangs, "custom languages")
|
||||
|
||||
verifyStringSliceLength(t, DisabledImageExtensions(), []string{".gif", ".bmp"}, "disabled image extensions")
|
||||
verifyStringSliceLength(t, DisabledBinaryExtensions(), []string{".exe", ".dll"}, "disabled binary extensions")
|
||||
verifyStringSliceLength(t, DisabledLanguageExtensions(), []string{".rb", ".pl"}, "disabled language extensions")
|
||||
}
|
||||
|
||||
// setValidConfiguration sets valid configuration for validation tests.
|
||||
func setValidConfiguration() {
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomImageExtensions, []string{".webp", ".avif"})
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomBinaryExtensions, []string{shared.TestExtensionCustom})
|
||||
viper.Set(
|
||||
shared.ConfigKeyFileTypesCustomLanguages, map[string]string{
|
||||
".zig": "zig",
|
||||
".v": "vlang",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// testInvalidImageExtensions tests validation failure with invalid image extensions.
|
||||
func testInvalidImageExtensions(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomImageExtensions, []string{"", "webp"}) // Empty and missing dot
|
||||
|
||||
err := ValidateConfig()
|
||||
if err == nil {
|
||||
t.Error("Expected validation to fail with invalid custom image extensions")
|
||||
}
|
||||
}
|
||||
|
||||
// testInvalidBinaryExtensions tests validation failure with invalid binary extensions.
|
||||
func testInvalidBinaryExtensions(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
viper.Set(shared.ConfigKeyFileTypesCustomBinaryExtensions, []string{"custom"}) // Missing dot
|
||||
|
||||
err := ValidateConfig()
|
||||
if err == nil {
|
||||
t.Error("Expected validation to fail with invalid custom binary extensions")
|
||||
}
|
||||
}
|
||||
|
||||
// testInvalidCustomLanguages tests validation failure with invalid custom languages.
|
||||
func testInvalidCustomLanguages(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
viper.Set(
|
||||
shared.ConfigKeyFileTypesCustomLanguages, map[string]string{
|
||||
"zig": "zig", // Missing dot in extension
|
||||
".v": "", // Empty language
|
||||
},
|
||||
)
|
||||
|
||||
err := ValidateConfig()
|
||||
if err == nil {
|
||||
t.Error("Expected validation to fail with invalid custom languages")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyEmptySlice verifies that a slice is empty.
|
||||
func verifyEmptySlice(t *testing.T, slice []string, name string) {
|
||||
t.Helper()
|
||||
|
||||
if len(slice) != 0 {
|
||||
t.Errorf("Expected %s to be empty by default", name)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyEmptyMap verifies that a map is empty.
|
||||
func verifyEmptyMap(t *testing.T, m map[string]string, name string) {
|
||||
t.Helper()
|
||||
|
||||
if len(m) != 0 {
|
||||
t.Errorf("Expected %s to be empty by default", name)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyStringSlice verifies that a string slice matches expected values.
|
||||
func verifyStringSlice(t *testing.T, actual, expected []string, name string) {
|
||||
t.Helper()
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf(shared.TestFmtExpectedCount, len(expected), name, len(actual))
|
||||
|
||||
return
|
||||
}
|
||||
for i, ext := range expected {
|
||||
if actual[i] != ext {
|
||||
t.Errorf("Expected %s %s, got %s", name, ext, actual[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyStringMap verifies that a string map matches expected values.
|
||||
func verifyStringMap(t *testing.T, actual, expected map[string]string, name string) {
|
||||
t.Helper()
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf(shared.TestFmtExpectedCount, len(expected), name, len(actual))
|
||||
|
||||
return
|
||||
}
|
||||
for ext, lang := range expected {
|
||||
if actual[ext] != lang {
|
||||
t.Errorf("Expected %s %s -> %s, got %s", name, ext, lang, actual[ext])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyStringSliceLength verifies that a string slice has the expected length.
|
||||
func verifyStringSliceLength(t *testing.T, actual, expected []string, name string) {
|
||||
t.Helper()
|
||||
|
||||
if len(actual) != len(expected) {
|
||||
t.Errorf(shared.TestFmtExpectedCount, len(expected), name, len(actual))
|
||||
}
|
||||
}
|
||||
331
config/getters.go
Normal file
331
config/getters.go
Normal file
@@ -0,0 +1,331 @@
|
||||
// Package config handles application configuration management.
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// FileSizeLimit returns the file size limit from configuration.
|
||||
// Default: ConfigFileSizeLimitDefault (5MB).
|
||||
func FileSizeLimit() int64 {
|
||||
return viper.GetInt64(shared.ConfigKeyFileSizeLimit)
|
||||
}
|
||||
|
||||
// IgnoredDirectories returns the list of directories to ignore.
|
||||
// Default: ConfigIgnoredDirectoriesDefault.
|
||||
func IgnoredDirectories() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyIgnoreDirectories)
|
||||
}
|
||||
|
||||
// MaxConcurrency returns the maximum concurrency level.
|
||||
// Returns 0 if not set (caller should determine appropriate default).
|
||||
func MaxConcurrency() int {
|
||||
return viper.GetInt(shared.ConfigKeyMaxConcurrency)
|
||||
}
|
||||
|
||||
// SupportedFormats returns the list of supported output formats.
|
||||
// Returns empty slice if not set.
|
||||
func SupportedFormats() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeySupportedFormats)
|
||||
}
|
||||
|
||||
// FilePatterns returns the list of file patterns.
|
||||
// Returns empty slice if not set.
|
||||
func FilePatterns() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFilePatterns)
|
||||
}
|
||||
|
||||
// IsValidFormat checks if the given format is valid.
|
||||
func IsValidFormat(format string) bool {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
supportedFormats := map[string]bool{
|
||||
shared.FormatJSON: true,
|
||||
shared.FormatYAML: true,
|
||||
shared.FormatMarkdown: true,
|
||||
}
|
||||
|
||||
return supportedFormats[format]
|
||||
}
|
||||
|
||||
// FileTypesEnabled returns whether file types are enabled.
|
||||
// Default: ConfigFileTypesEnabledDefault (true).
|
||||
func FileTypesEnabled() bool {
|
||||
return viper.GetBool(shared.ConfigKeyFileTypesEnabled)
|
||||
}
|
||||
|
||||
// CustomImageExtensions returns custom image extensions.
|
||||
// Default: ConfigCustomImageExtensionsDefault (empty).
|
||||
func CustomImageExtensions() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFileTypesCustomImageExtensions)
|
||||
}
|
||||
|
||||
// CustomBinaryExtensions returns custom binary extensions.
|
||||
// Default: ConfigCustomBinaryExtensionsDefault (empty).
|
||||
func CustomBinaryExtensions() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFileTypesCustomBinaryExtensions)
|
||||
}
|
||||
|
||||
// CustomLanguages returns custom language mappings.
|
||||
// Default: ConfigCustomLanguagesDefault (empty).
|
||||
func CustomLanguages() map[string]string {
|
||||
return viper.GetStringMapString(shared.ConfigKeyFileTypesCustomLanguages)
|
||||
}
|
||||
|
||||
// DisabledImageExtensions returns disabled image extensions.
|
||||
// Default: ConfigDisabledImageExtensionsDefault (empty).
|
||||
func DisabledImageExtensions() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFileTypesDisabledImageExtensions)
|
||||
}
|
||||
|
||||
// DisabledBinaryExtensions returns disabled binary extensions.
|
||||
// Default: ConfigDisabledBinaryExtensionsDefault (empty).
|
||||
func DisabledBinaryExtensions() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFileTypesDisabledBinaryExtensions)
|
||||
}
|
||||
|
||||
// DisabledLanguageExtensions returns disabled language extensions.
|
||||
// Default: ConfigDisabledLanguageExtensionsDefault (empty).
|
||||
func DisabledLanguageExtensions() []string {
|
||||
return viper.GetStringSlice(shared.ConfigKeyFileTypesDisabledLanguageExts)
|
||||
}
|
||||
|
||||
// Backpressure getters
|
||||
|
||||
// BackpressureEnabled returns whether backpressure is enabled.
|
||||
// Default: ConfigBackpressureEnabledDefault (true).
|
||||
func BackpressureEnabled() bool {
|
||||
return viper.GetBool(shared.ConfigKeyBackpressureEnabled)
|
||||
}
|
||||
|
||||
// MaxPendingFiles returns the maximum pending files.
|
||||
// Default: ConfigMaxPendingFilesDefault (1000).
|
||||
func MaxPendingFiles() int {
|
||||
return viper.GetInt(shared.ConfigKeyBackpressureMaxPendingFiles)
|
||||
}
|
||||
|
||||
// MaxPendingWrites returns the maximum pending writes.
|
||||
// Default: ConfigMaxPendingWritesDefault (100).
|
||||
func MaxPendingWrites() int {
|
||||
return viper.GetInt(shared.ConfigKeyBackpressureMaxPendingWrites)
|
||||
}
|
||||
|
||||
// MaxMemoryUsage returns the maximum memory usage.
|
||||
// Default: ConfigMaxMemoryUsageDefault (100MB).
|
||||
func MaxMemoryUsage() int64 {
|
||||
return viper.GetInt64(shared.ConfigKeyBackpressureMaxMemoryUsage)
|
||||
}
|
||||
|
||||
// MemoryCheckInterval returns the memory check interval.
|
||||
// Default: ConfigMemoryCheckIntervalDefault (1000 files).
|
||||
func MemoryCheckInterval() int {
|
||||
return viper.GetInt(shared.ConfigKeyBackpressureMemoryCheckInt)
|
||||
}
|
||||
|
||||
// Resource limits getters
|
||||
|
||||
// ResourceLimitsEnabled returns whether resource limits are enabled.
|
||||
// Default: ConfigResourceLimitsEnabledDefault (true).
|
||||
func ResourceLimitsEnabled() bool {
|
||||
return viper.GetBool(shared.ConfigKeyResourceLimitsEnabled)
|
||||
}
|
||||
|
||||
// MaxFiles returns the maximum number of files.
|
||||
// Default: ConfigMaxFilesDefault (10000).
|
||||
func MaxFiles() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsMaxFiles)
|
||||
}
|
||||
|
||||
// MaxTotalSize returns the maximum total size.
|
||||
// Default: ConfigMaxTotalSizeDefault (1GB).
|
||||
func MaxTotalSize() int64 {
|
||||
return viper.GetInt64(shared.ConfigKeyResourceLimitsMaxTotalSize)
|
||||
}
|
||||
|
||||
// FileProcessingTimeoutSec returns the file processing timeout in seconds.
|
||||
// Default: ConfigFileProcessingTimeoutSecDefault (30 seconds).
|
||||
func FileProcessingTimeoutSec() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsFileProcessingTO)
|
||||
}
|
||||
|
||||
// OverallTimeoutSec returns the overall timeout in seconds.
|
||||
// Default: ConfigOverallTimeoutSecDefault (3600 seconds).
|
||||
func OverallTimeoutSec() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsOverallTO)
|
||||
}
|
||||
|
||||
// MaxConcurrentReads returns the maximum concurrent reads.
|
||||
// Default: ConfigMaxConcurrentReadsDefault (10).
|
||||
func MaxConcurrentReads() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsMaxConcurrentReads)
|
||||
}
|
||||
|
||||
// RateLimitFilesPerSec returns the rate limit files per second.
|
||||
// Default: ConfigRateLimitFilesPerSecDefault (0 = disabled).
|
||||
func RateLimitFilesPerSec() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsRateLimitFilesPerSec)
|
||||
}
|
||||
|
||||
// HardMemoryLimitMB returns the hard memory limit in MB.
|
||||
// Default: ConfigHardMemoryLimitMBDefault (512MB).
|
||||
func HardMemoryLimitMB() int {
|
||||
return viper.GetInt(shared.ConfigKeyResourceLimitsHardMemoryLimitMB)
|
||||
}
|
||||
|
||||
// EnableGracefulDegradation returns whether graceful degradation is enabled.
|
||||
// Default: ConfigEnableGracefulDegradationDefault (true).
|
||||
func EnableGracefulDegradation() bool {
|
||||
return viper.GetBool(shared.ConfigKeyResourceLimitsEnableGracefulDeg)
|
||||
}
|
||||
|
||||
// EnableResourceMonitoring returns whether resource monitoring is enabled.
|
||||
// Default: ConfigEnableResourceMonitoringDefault (true).
|
||||
func EnableResourceMonitoring() bool {
|
||||
return viper.GetBool(shared.ConfigKeyResourceLimitsEnableMonitoring)
|
||||
}
|
||||
|
||||
// Template system getters
|
||||
|
||||
// OutputTemplate returns the selected output template name.
|
||||
// Default: ConfigOutputTemplateDefault (empty string).
|
||||
func OutputTemplate() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputTemplate)
|
||||
}
|
||||
|
||||
// metadataBool is a helper for metadata boolean configuration values.
|
||||
// All metadata flags default to false.
|
||||
func metadataBool(key string) bool {
|
||||
return viper.GetBool("output.metadata." + key)
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeStats returns whether to include stats in metadata.
|
||||
func TemplateMetadataIncludeStats() bool {
|
||||
return metadataBool("includeStats")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeTimestamp returns whether to include timestamp in metadata.
|
||||
func TemplateMetadataIncludeTimestamp() bool {
|
||||
return metadataBool("includeTimestamp")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeFileCount returns whether to include file count in metadata.
|
||||
func TemplateMetadataIncludeFileCount() bool {
|
||||
return metadataBool("includeFileCount")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeSourcePath returns whether to include source path in metadata.
|
||||
func TemplateMetadataIncludeSourcePath() bool {
|
||||
return metadataBool("includeSourcePath")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeFileTypes returns whether to include file types in metadata.
|
||||
func TemplateMetadataIncludeFileTypes() bool {
|
||||
return metadataBool("includeFileTypes")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeProcessingTime returns whether to include processing time in metadata.
|
||||
func TemplateMetadataIncludeProcessingTime() bool {
|
||||
return metadataBool("includeProcessingTime")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeTotalSize returns whether to include total size in metadata.
|
||||
func TemplateMetadataIncludeTotalSize() bool {
|
||||
return metadataBool("includeTotalSize")
|
||||
}
|
||||
|
||||
// TemplateMetadataIncludeMetrics returns whether to include metrics in metadata.
|
||||
func TemplateMetadataIncludeMetrics() bool {
|
||||
return metadataBool("includeMetrics")
|
||||
}
|
||||
|
||||
// markdownBool is a helper for markdown boolean configuration values.
|
||||
// All markdown flags default to false.
|
||||
func markdownBool(key string) bool {
|
||||
return viper.GetBool("output.markdown." + key)
|
||||
}
|
||||
|
||||
// TemplateMarkdownUseCodeBlocks returns whether to use code blocks in markdown.
|
||||
func TemplateMarkdownUseCodeBlocks() bool {
|
||||
return markdownBool("useCodeBlocks")
|
||||
}
|
||||
|
||||
// TemplateMarkdownIncludeLanguage returns whether to include language in code blocks.
|
||||
func TemplateMarkdownIncludeLanguage() bool {
|
||||
return markdownBool("includeLanguage")
|
||||
}
|
||||
|
||||
// TemplateMarkdownHeaderLevel returns the header level for file sections.
|
||||
// Default: ConfigMarkdownHeaderLevelDefault (0).
|
||||
func TemplateMarkdownHeaderLevel() int {
|
||||
return viper.GetInt(shared.ConfigKeyOutputMarkdownHeaderLevel)
|
||||
}
|
||||
|
||||
// TemplateMarkdownTableOfContents returns whether to include table of contents.
|
||||
func TemplateMarkdownTableOfContents() bool {
|
||||
return markdownBool("tableOfContents")
|
||||
}
|
||||
|
||||
// TemplateMarkdownUseCollapsible returns whether to use collapsible sections.
|
||||
func TemplateMarkdownUseCollapsible() bool {
|
||||
return markdownBool("useCollapsible")
|
||||
}
|
||||
|
||||
// TemplateMarkdownSyntaxHighlighting returns whether to enable syntax highlighting.
|
||||
func TemplateMarkdownSyntaxHighlighting() bool {
|
||||
return markdownBool("syntaxHighlighting")
|
||||
}
|
||||
|
||||
// TemplateMarkdownLineNumbers returns whether to include line numbers.
|
||||
func TemplateMarkdownLineNumbers() bool {
|
||||
return markdownBool("lineNumbers")
|
||||
}
|
||||
|
||||
// TemplateMarkdownFoldLongFiles returns whether to fold long files.
|
||||
func TemplateMarkdownFoldLongFiles() bool {
|
||||
return markdownBool("foldLongFiles")
|
||||
}
|
||||
|
||||
// TemplateMarkdownMaxLineLength returns the maximum line length.
|
||||
// Default: ConfigMarkdownMaxLineLengthDefault (0 = unlimited).
|
||||
func TemplateMarkdownMaxLineLength() int {
|
||||
return viper.GetInt(shared.ConfigKeyOutputMarkdownMaxLineLen)
|
||||
}
|
||||
|
||||
// TemplateCustomCSS returns custom CSS for markdown output.
|
||||
// Default: ConfigMarkdownCustomCSSDefault (empty string).
|
||||
func TemplateCustomCSS() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputMarkdownCustomCSS)
|
||||
}
|
||||
|
||||
// TemplateCustomHeader returns custom header template.
|
||||
// Default: ConfigCustomHeaderDefault (empty string).
|
||||
func TemplateCustomHeader() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputCustomHeader)
|
||||
}
|
||||
|
||||
// TemplateCustomFooter returns custom footer template.
|
||||
// Default: ConfigCustomFooterDefault (empty string).
|
||||
func TemplateCustomFooter() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputCustomFooter)
|
||||
}
|
||||
|
||||
// TemplateCustomFileHeader returns custom file header template.
|
||||
// Default: ConfigCustomFileHeaderDefault (empty string).
|
||||
func TemplateCustomFileHeader() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputCustomFileHeader)
|
||||
}
|
||||
|
||||
// TemplateCustomFileFooter returns custom file footer template.
|
||||
// Default: ConfigCustomFileFooterDefault (empty string).
|
||||
func TemplateCustomFileFooter() string {
|
||||
return viper.GetString(shared.ConfigKeyOutputCustomFileFooter)
|
||||
}
|
||||
|
||||
// TemplateVariables returns custom template variables.
|
||||
// Default: ConfigTemplateVariablesDefault (empty map).
|
||||
func TemplateVariables() map[string]string {
|
||||
return viper.GetStringMapString(shared.ConfigKeyOutputVariables)
|
||||
}
|
||||
492
config/getters_test.go
Normal file
492
config/getters_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
// TestConfigGetters tests all configuration getter functions with comprehensive test coverage.
|
||||
func TestConfigGetters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configKey string
|
||||
configValue any
|
||||
getterFunc func() any
|
||||
expectedResult any
|
||||
}{
|
||||
// Basic configuration getters
|
||||
{
|
||||
name: "GetFileSizeLimit",
|
||||
configKey: "fileSizeLimit",
|
||||
configValue: int64(1048576),
|
||||
getterFunc: func() any { return config.FileSizeLimit() },
|
||||
expectedResult: int64(1048576),
|
||||
},
|
||||
{
|
||||
name: "GetIgnoredDirectories",
|
||||
configKey: "ignoreDirectories",
|
||||
configValue: []string{"node_modules", ".git", "dist"},
|
||||
getterFunc: func() any { return config.IgnoredDirectories() },
|
||||
expectedResult: []string{"node_modules", ".git", "dist"},
|
||||
},
|
||||
{
|
||||
name: "GetMaxConcurrency",
|
||||
configKey: "maxConcurrency",
|
||||
configValue: 8,
|
||||
getterFunc: func() any { return config.MaxConcurrency() },
|
||||
expectedResult: 8,
|
||||
},
|
||||
{
|
||||
name: "GetSupportedFormats",
|
||||
configKey: "supportedFormats",
|
||||
configValue: []string{"json", "yaml", "markdown"},
|
||||
getterFunc: func() any { return config.SupportedFormats() },
|
||||
expectedResult: []string{"json", "yaml", "markdown"},
|
||||
},
|
||||
{
|
||||
name: "GetFilePatterns",
|
||||
configKey: "filePatterns",
|
||||
configValue: []string{"*.go", "*.js", "*.py"},
|
||||
getterFunc: func() any { return config.FilePatterns() },
|
||||
expectedResult: []string{"*.go", "*.js", "*.py"},
|
||||
},
|
||||
|
||||
// File type configuration getters
|
||||
{
|
||||
name: "GetFileTypesEnabled",
|
||||
configKey: "fileTypes.enabled",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.FileTypesEnabled() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetCustomImageExtensions",
|
||||
configKey: "fileTypes.customImageExtensions",
|
||||
configValue: []string{".webp", ".avif"},
|
||||
getterFunc: func() any { return config.CustomImageExtensions() },
|
||||
expectedResult: []string{".webp", ".avif"},
|
||||
},
|
||||
{
|
||||
name: "GetCustomBinaryExtensions",
|
||||
configKey: "fileTypes.customBinaryExtensions",
|
||||
configValue: []string{".custom", ".bin"},
|
||||
getterFunc: func() any { return config.CustomBinaryExtensions() },
|
||||
expectedResult: []string{".custom", ".bin"},
|
||||
},
|
||||
{
|
||||
name: "GetDisabledImageExtensions",
|
||||
configKey: "fileTypes.disabledImageExtensions",
|
||||
configValue: []string{".gif", ".bmp"},
|
||||
getterFunc: func() any { return config.DisabledImageExtensions() },
|
||||
expectedResult: []string{".gif", ".bmp"},
|
||||
},
|
||||
{
|
||||
name: "GetDisabledBinaryExtensions",
|
||||
configKey: "fileTypes.disabledBinaryExtensions",
|
||||
configValue: []string{".exe", ".dll"},
|
||||
getterFunc: func() any { return config.DisabledBinaryExtensions() },
|
||||
expectedResult: []string{".exe", ".dll"},
|
||||
},
|
||||
{
|
||||
name: "GetDisabledLanguageExtensions",
|
||||
configKey: "fileTypes.disabledLanguageExtensions",
|
||||
configValue: []string{".sh", ".bat"},
|
||||
getterFunc: func() any { return config.DisabledLanguageExtensions() },
|
||||
expectedResult: []string{".sh", ".bat"},
|
||||
},
|
||||
|
||||
// Backpressure configuration getters
|
||||
{
|
||||
name: "GetBackpressureEnabled",
|
||||
configKey: "backpressure.enabled",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.BackpressureEnabled() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetMaxPendingFiles",
|
||||
configKey: "backpressure.maxPendingFiles",
|
||||
configValue: 1000,
|
||||
getterFunc: func() any { return config.MaxPendingFiles() },
|
||||
expectedResult: 1000,
|
||||
},
|
||||
{
|
||||
name: "GetMaxPendingWrites",
|
||||
configKey: "backpressure.maxPendingWrites",
|
||||
configValue: 100,
|
||||
getterFunc: func() any { return config.MaxPendingWrites() },
|
||||
expectedResult: 100,
|
||||
},
|
||||
{
|
||||
name: "GetMaxMemoryUsage",
|
||||
configKey: "backpressure.maxMemoryUsage",
|
||||
configValue: int64(104857600),
|
||||
getterFunc: func() any { return config.MaxMemoryUsage() },
|
||||
expectedResult: int64(104857600),
|
||||
},
|
||||
{
|
||||
name: "GetMemoryCheckInterval",
|
||||
configKey: "backpressure.memoryCheckInterval",
|
||||
configValue: 500,
|
||||
getterFunc: func() any { return config.MemoryCheckInterval() },
|
||||
expectedResult: 500,
|
||||
},
|
||||
|
||||
// Resource limits configuration getters
|
||||
{
|
||||
name: "GetResourceLimitsEnabled",
|
||||
configKey: "resourceLimits.enabled",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.ResourceLimitsEnabled() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetMaxFiles",
|
||||
configKey: "resourceLimits.maxFiles",
|
||||
configValue: 5000,
|
||||
getterFunc: func() any { return config.MaxFiles() },
|
||||
expectedResult: 5000,
|
||||
},
|
||||
{
|
||||
name: "GetMaxTotalSize",
|
||||
configKey: "resourceLimits.maxTotalSize",
|
||||
configValue: int64(1073741824),
|
||||
getterFunc: func() any { return config.MaxTotalSize() },
|
||||
expectedResult: int64(1073741824),
|
||||
},
|
||||
{
|
||||
name: "GetFileProcessingTimeoutSec",
|
||||
configKey: "resourceLimits.fileProcessingTimeoutSec",
|
||||
configValue: 30,
|
||||
getterFunc: func() any { return config.FileProcessingTimeoutSec() },
|
||||
expectedResult: 30,
|
||||
},
|
||||
{
|
||||
name: "GetOverallTimeoutSec",
|
||||
configKey: "resourceLimits.overallTimeoutSec",
|
||||
configValue: 1800,
|
||||
getterFunc: func() any { return config.OverallTimeoutSec() },
|
||||
expectedResult: 1800,
|
||||
},
|
||||
{
|
||||
name: "GetMaxConcurrentReads",
|
||||
configKey: "resourceLimits.maxConcurrentReads",
|
||||
configValue: 10,
|
||||
getterFunc: func() any { return config.MaxConcurrentReads() },
|
||||
expectedResult: 10,
|
||||
},
|
||||
{
|
||||
name: "GetRateLimitFilesPerSec",
|
||||
configKey: "resourceLimits.rateLimitFilesPerSec",
|
||||
configValue: 100,
|
||||
getterFunc: func() any { return config.RateLimitFilesPerSec() },
|
||||
expectedResult: 100,
|
||||
},
|
||||
{
|
||||
name: "GetHardMemoryLimitMB",
|
||||
configKey: "resourceLimits.hardMemoryLimitMB",
|
||||
configValue: 512,
|
||||
getterFunc: func() any { return config.HardMemoryLimitMB() },
|
||||
expectedResult: 512,
|
||||
},
|
||||
{
|
||||
name: "GetEnableGracefulDegradation",
|
||||
configKey: "resourceLimits.enableGracefulDegradation",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.EnableGracefulDegradation() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetEnableResourceMonitoring",
|
||||
configKey: "resourceLimits.enableResourceMonitoring",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.EnableResourceMonitoring() },
|
||||
expectedResult: true,
|
||||
},
|
||||
|
||||
// Template system configuration getters
|
||||
{
|
||||
name: "GetOutputTemplate",
|
||||
configKey: "output.template",
|
||||
configValue: "detailed",
|
||||
getterFunc: func() any { return config.OutputTemplate() },
|
||||
expectedResult: "detailed",
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeStats",
|
||||
configKey: "output.metadata.includeStats",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeStats() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeTimestamp",
|
||||
configKey: "output.metadata.includeTimestamp",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeTimestamp() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeFileCount",
|
||||
configKey: "output.metadata.includeFileCount",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeFileCount() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeSourcePath",
|
||||
configKey: "output.metadata.includeSourcePath",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeSourcePath() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeFileTypes",
|
||||
configKey: "output.metadata.includeFileTypes",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeFileTypes() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeProcessingTime",
|
||||
configKey: "output.metadata.includeProcessingTime",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeProcessingTime() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeTotalSize",
|
||||
configKey: "output.metadata.includeTotalSize",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeTotalSize() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMetadataIncludeMetrics",
|
||||
configKey: "output.metadata.includeMetrics",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMetadataIncludeMetrics() },
|
||||
expectedResult: false,
|
||||
},
|
||||
|
||||
// Markdown template configuration getters
|
||||
{
|
||||
name: "GetTemplateMarkdownUseCodeBlocks",
|
||||
configKey: "output.markdown.useCodeBlocks",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMarkdownUseCodeBlocks() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownIncludeLanguage",
|
||||
configKey: "output.markdown.includeLanguage",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMarkdownIncludeLanguage() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownHeaderLevel",
|
||||
configKey: "output.markdown.headerLevel",
|
||||
configValue: 3,
|
||||
getterFunc: func() any { return config.TemplateMarkdownHeaderLevel() },
|
||||
expectedResult: 3,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownTableOfContents",
|
||||
configKey: "output.markdown.tableOfContents",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMarkdownTableOfContents() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownUseCollapsible",
|
||||
configKey: "output.markdown.useCollapsible",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMarkdownUseCollapsible() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownSyntaxHighlighting",
|
||||
configKey: "output.markdown.syntaxHighlighting",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMarkdownSyntaxHighlighting() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownLineNumbers",
|
||||
configKey: "output.markdown.lineNumbers",
|
||||
configValue: false,
|
||||
getterFunc: func() any { return config.TemplateMarkdownLineNumbers() },
|
||||
expectedResult: false,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownFoldLongFiles",
|
||||
configKey: "output.markdown.foldLongFiles",
|
||||
configValue: true,
|
||||
getterFunc: func() any { return config.TemplateMarkdownFoldLongFiles() },
|
||||
expectedResult: true,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateMarkdownMaxLineLength",
|
||||
configKey: "output.markdown.maxLineLength",
|
||||
configValue: 120,
|
||||
getterFunc: func() any { return config.TemplateMarkdownMaxLineLength() },
|
||||
expectedResult: 120,
|
||||
},
|
||||
{
|
||||
name: "GetTemplateCustomCSS",
|
||||
configKey: "output.markdown.customCSS",
|
||||
configValue: "body { color: blue; }",
|
||||
getterFunc: func() any { return config.TemplateCustomCSS() },
|
||||
expectedResult: "body { color: blue; }",
|
||||
},
|
||||
|
||||
// Custom template configuration getters
|
||||
{
|
||||
name: "GetTemplateCustomHeader",
|
||||
configKey: "output.custom.header",
|
||||
configValue: "# Custom Header\n",
|
||||
getterFunc: func() any { return config.TemplateCustomHeader() },
|
||||
expectedResult: "# Custom Header\n",
|
||||
},
|
||||
{
|
||||
name: "GetTemplateCustomFooter",
|
||||
configKey: "output.custom.footer",
|
||||
configValue: "---\nFooter content",
|
||||
getterFunc: func() any { return config.TemplateCustomFooter() },
|
||||
expectedResult: "---\nFooter content",
|
||||
},
|
||||
{
|
||||
name: "GetTemplateCustomFileHeader",
|
||||
configKey: "output.custom.fileHeader",
|
||||
configValue: "## File: {{ .Path }}",
|
||||
getterFunc: func() any { return config.TemplateCustomFileHeader() },
|
||||
expectedResult: "## File: {{ .Path }}",
|
||||
},
|
||||
{
|
||||
name: "GetTemplateCustomFileFooter",
|
||||
configKey: "output.custom.fileFooter",
|
||||
configValue: "---",
|
||||
getterFunc: func() any { return config.TemplateCustomFileFooter() },
|
||||
expectedResult: "---",
|
||||
},
|
||||
|
||||
// Custom languages map getter
|
||||
{
|
||||
name: "GetCustomLanguages",
|
||||
configKey: "fileTypes.customLanguages",
|
||||
configValue: map[string]string{".vue": "vue", ".svelte": "svelte"},
|
||||
getterFunc: func() any { return config.CustomLanguages() },
|
||||
expectedResult: map[string]string{".vue": "vue", ".svelte": "svelte"},
|
||||
},
|
||||
|
||||
// Template variables map getter
|
||||
{
|
||||
name: "GetTemplateVariables",
|
||||
configKey: "output.variables",
|
||||
configValue: map[string]string{"project": "gibidify", "version": "1.0"},
|
||||
getterFunc: func() any { return config.TemplateVariables() },
|
||||
expectedResult: map[string]string{"project": "gibidify", "version": "1.0"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset viper and set the specific configuration
|
||||
testutil.SetViperKeys(t, map[string]any{
|
||||
tt.configKey: tt.configValue,
|
||||
})
|
||||
|
||||
// Call the getter function and compare results
|
||||
result := tt.getterFunc()
|
||||
if !reflect.DeepEqual(result, tt.expectedResult) {
|
||||
t.Errorf("Test %s: expected %v (type %T), got %v (type %T)",
|
||||
tt.name, tt.expectedResult, tt.expectedResult, result, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigGettersWithDefaults tests that getters return appropriate default values
|
||||
// when configuration keys are not set.
|
||||
func TestConfigGettersWithDefaults(t *testing.T) {
|
||||
// Reset viper to ensure clean state
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Test numeric getters with concrete default assertions
|
||||
t.Run("numeric_getters", func(t *testing.T) {
|
||||
assertInt64Getter(t, "FileSizeLimit", config.FileSizeLimit, shared.ConfigFileSizeLimitDefault)
|
||||
assertIntGetter(t, "MaxConcurrency", config.MaxConcurrency, shared.ConfigMaxConcurrencyDefault)
|
||||
assertIntGetter(t, "TemplateMarkdownHeaderLevel", config.TemplateMarkdownHeaderLevel,
|
||||
shared.ConfigMarkdownHeaderLevelDefault)
|
||||
assertIntGetter(t, "MaxFiles", config.MaxFiles, shared.ConfigMaxFilesDefault)
|
||||
assertInt64Getter(t, "MaxTotalSize", config.MaxTotalSize, shared.ConfigMaxTotalSizeDefault)
|
||||
assertIntGetter(t, "FileProcessingTimeoutSec", config.FileProcessingTimeoutSec,
|
||||
shared.ConfigFileProcessingTimeoutSecDefault)
|
||||
assertIntGetter(t, "OverallTimeoutSec", config.OverallTimeoutSec, shared.ConfigOverallTimeoutSecDefault)
|
||||
assertIntGetter(t, "MaxConcurrentReads", config.MaxConcurrentReads, shared.ConfigMaxConcurrentReadsDefault)
|
||||
assertIntGetter(t, "HardMemoryLimitMB", config.HardMemoryLimitMB, shared.ConfigHardMemoryLimitMBDefault)
|
||||
})
|
||||
|
||||
// Test boolean getters with concrete default assertions
|
||||
t.Run("boolean_getters", func(t *testing.T) {
|
||||
assertBoolGetter(t, "FileTypesEnabled", config.FileTypesEnabled, shared.ConfigFileTypesEnabledDefault)
|
||||
assertBoolGetter(t, "BackpressureEnabled", config.BackpressureEnabled, shared.ConfigBackpressureEnabledDefault)
|
||||
assertBoolGetter(t, "ResourceLimitsEnabled", config.ResourceLimitsEnabled,
|
||||
shared.ConfigResourceLimitsEnabledDefault)
|
||||
assertBoolGetter(t, "EnableGracefulDegradation", config.EnableGracefulDegradation,
|
||||
shared.ConfigEnableGracefulDegradationDefault)
|
||||
assertBoolGetter(t, "TemplateMarkdownUseCodeBlocks", config.TemplateMarkdownUseCodeBlocks,
|
||||
shared.ConfigMarkdownUseCodeBlocksDefault)
|
||||
assertBoolGetter(t, "TemplateMarkdownTableOfContents", config.TemplateMarkdownTableOfContents,
|
||||
shared.ConfigMarkdownTableOfContentsDefault)
|
||||
})
|
||||
|
||||
// Test string getters with concrete default assertions
|
||||
t.Run("string_getters", func(t *testing.T) {
|
||||
assertStringGetter(t, "OutputTemplate", config.OutputTemplate, shared.ConfigOutputTemplateDefault)
|
||||
assertStringGetter(t, "TemplateCustomCSS", config.TemplateCustomCSS, shared.ConfigMarkdownCustomCSSDefault)
|
||||
assertStringGetter(t, "TemplateCustomHeader", config.TemplateCustomHeader, shared.ConfigCustomHeaderDefault)
|
||||
assertStringGetter(t, "TemplateCustomFooter", config.TemplateCustomFooter, shared.ConfigCustomFooterDefault)
|
||||
})
|
||||
}
|
||||
|
||||
// assertInt64Getter tests an int64 getter returns the expected default value.
|
||||
func assertInt64Getter(t *testing.T, name string, getter func() int64, expected int64) {
|
||||
t.Helper()
|
||||
result := getter()
|
||||
if result != expected {
|
||||
t.Errorf("%s: expected %d, got %d", name, expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// assertIntGetter tests an int getter returns the expected default value.
|
||||
func assertIntGetter(t *testing.T, name string, getter func() int, expected int) {
|
||||
t.Helper()
|
||||
result := getter()
|
||||
if result != expected {
|
||||
t.Errorf("%s: expected %d, got %d", name, expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// assertBoolGetter tests a bool getter returns the expected default value.
|
||||
func assertBoolGetter(t *testing.T, name string, getter func() bool, expected bool) {
|
||||
t.Helper()
|
||||
result := getter()
|
||||
if result != expected {
|
||||
t.Errorf("%s: expected %v, got %v", name, expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
// assertStringGetter tests a string getter returns the expected default value.
|
||||
func assertStringGetter(t *testing.T, name string, getter func() string, expected string) {
|
||||
t.Helper()
|
||||
result := getter()
|
||||
if result != expected {
|
||||
t.Errorf("%s: expected %q, got %q", name, expected, result)
|
||||
}
|
||||
}
|
||||
119
config/loader.go
Normal file
119
config/loader.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Package config handles application configuration management.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// LoadConfig reads configuration from a YAML file.
|
||||
// It looks for config in the following order:
|
||||
// 1. $XDG_CONFIG_HOME/gibidify/config.yaml
|
||||
// 2. $HOME/.config/gibidify/config.yaml
|
||||
// 3. The current directory as fallback.
|
||||
func LoadConfig() {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType(shared.FormatYAML)
|
||||
|
||||
logger := shared.GetLogger()
|
||||
|
||||
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
|
||||
// Validate XDG_CONFIG_HOME for path traversal attempts
|
||||
if err := shared.ValidateConfigPath(xdgConfig); err != nil {
|
||||
logger.Warnf("Invalid XDG_CONFIG_HOME path, using default config: %v", err)
|
||||
} else {
|
||||
configPath := filepath.Join(xdgConfig, shared.AppName)
|
||||
viper.AddConfigPath(configPath)
|
||||
}
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
viper.AddConfigPath(filepath.Join(home, ".config", shared.AppName))
|
||||
}
|
||||
// Only add current directory if no config file named gibidify.yaml exists
|
||||
// to avoid conflicts with the project's output file
|
||||
if _, err := os.Stat(shared.AppName + ".yaml"); os.IsNotExist(err) {
|
||||
viper.AddConfigPath(".")
|
||||
}
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
logger.Infof("Config file not found, using default values: %v", err)
|
||||
SetDefaultConfig()
|
||||
} else {
|
||||
logger.Infof("Using config file: %s", viper.ConfigFileUsed())
|
||||
// Validate configuration after loading
|
||||
if err := ValidateConfig(); err != nil {
|
||||
logger.Warnf("Configuration validation failed: %v", err)
|
||||
logger.Info("Falling back to default configuration")
|
||||
// Reset viper and set defaults when validation fails
|
||||
viper.Reset()
|
||||
SetDefaultConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultConfig sets default configuration values.
|
||||
func SetDefaultConfig() {
|
||||
// File size limits
|
||||
viper.SetDefault(shared.ConfigKeyFileSizeLimit, shared.ConfigFileSizeLimitDefault)
|
||||
viper.SetDefault(shared.ConfigKeyIgnoreDirectories, shared.ConfigIgnoredDirectoriesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyMaxConcurrency, shared.ConfigMaxConcurrencyDefault)
|
||||
viper.SetDefault(shared.ConfigKeySupportedFormats, shared.ConfigSupportedFormatsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFilePatterns, shared.ConfigFilePatternsDefault)
|
||||
|
||||
// FileTypeRegistry defaults
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesEnabled, shared.ConfigFileTypesEnabledDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesCustomImageExtensions, shared.ConfigCustomImageExtensionsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesCustomBinaryExtensions, shared.ConfigCustomBinaryExtensionsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesCustomLanguages, shared.ConfigCustomLanguagesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesDisabledImageExtensions, shared.ConfigDisabledImageExtensionsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesDisabledBinaryExtensions, shared.ConfigDisabledBinaryExtensionsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyFileTypesDisabledLanguageExts, shared.ConfigDisabledLanguageExtensionsDefault)
|
||||
|
||||
// Backpressure and memory management defaults
|
||||
viper.SetDefault(shared.ConfigKeyBackpressureEnabled, shared.ConfigBackpressureEnabledDefault)
|
||||
viper.SetDefault(shared.ConfigKeyBackpressureMaxPendingFiles, shared.ConfigMaxPendingFilesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyBackpressureMaxPendingWrites, shared.ConfigMaxPendingWritesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyBackpressureMaxMemoryUsage, shared.ConfigMaxMemoryUsageDefault)
|
||||
viper.SetDefault(shared.ConfigKeyBackpressureMemoryCheckInt, shared.ConfigMemoryCheckIntervalDefault)
|
||||
|
||||
// Resource limit defaults
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsEnabled, shared.ConfigResourceLimitsEnabledDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsMaxFiles, shared.ConfigMaxFilesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsMaxTotalSize, shared.ConfigMaxTotalSizeDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsFileProcessingTO, shared.ConfigFileProcessingTimeoutSecDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsOverallTO, shared.ConfigOverallTimeoutSecDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsMaxConcurrentReads, shared.ConfigMaxConcurrentReadsDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsRateLimitFilesPerSec, shared.ConfigRateLimitFilesPerSecDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsHardMemoryLimitMB, shared.ConfigHardMemoryLimitMBDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsEnableGracefulDeg, shared.ConfigEnableGracefulDegradationDefault)
|
||||
viper.SetDefault(shared.ConfigKeyResourceLimitsEnableMonitoring, shared.ConfigEnableResourceMonitoringDefault)
|
||||
|
||||
// Output configuration defaults
|
||||
viper.SetDefault(shared.ConfigKeyOutputTemplate, shared.ConfigOutputTemplateDefault)
|
||||
viper.SetDefault("output.metadata.includeStats", shared.ConfigMetadataIncludeStatsDefault)
|
||||
viper.SetDefault("output.metadata.includeTimestamp", shared.ConfigMetadataIncludeTimestampDefault)
|
||||
viper.SetDefault("output.metadata.includeFileCount", shared.ConfigMetadataIncludeFileCountDefault)
|
||||
viper.SetDefault("output.metadata.includeSourcePath", shared.ConfigMetadataIncludeSourcePathDefault)
|
||||
viper.SetDefault("output.metadata.includeFileTypes", shared.ConfigMetadataIncludeFileTypesDefault)
|
||||
viper.SetDefault("output.metadata.includeProcessingTime", shared.ConfigMetadataIncludeProcessingTimeDefault)
|
||||
viper.SetDefault("output.metadata.includeTotalSize", shared.ConfigMetadataIncludeTotalSizeDefault)
|
||||
viper.SetDefault("output.metadata.includeMetrics", shared.ConfigMetadataIncludeMetricsDefault)
|
||||
viper.SetDefault("output.markdown.useCodeBlocks", shared.ConfigMarkdownUseCodeBlocksDefault)
|
||||
viper.SetDefault("output.markdown.includeLanguage", shared.ConfigMarkdownIncludeLanguageDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputMarkdownHeaderLevel, shared.ConfigMarkdownHeaderLevelDefault)
|
||||
viper.SetDefault("output.markdown.tableOfContents", shared.ConfigMarkdownTableOfContentsDefault)
|
||||
viper.SetDefault("output.markdown.useCollapsible", shared.ConfigMarkdownUseCollapsibleDefault)
|
||||
viper.SetDefault("output.markdown.syntaxHighlighting", shared.ConfigMarkdownSyntaxHighlightingDefault)
|
||||
viper.SetDefault("output.markdown.lineNumbers", shared.ConfigMarkdownLineNumbersDefault)
|
||||
viper.SetDefault("output.markdown.foldLongFiles", shared.ConfigMarkdownFoldLongFilesDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputMarkdownMaxLineLen, shared.ConfigMarkdownMaxLineLengthDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputMarkdownCustomCSS, shared.ConfigMarkdownCustomCSSDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputCustomHeader, shared.ConfigCustomHeaderDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputCustomFooter, shared.ConfigCustomFooterDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputCustomFileHeader, shared.ConfigCustomFileHeaderDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputCustomFileFooter, shared.ConfigCustomFileFooterDefault)
|
||||
viper.SetDefault(shared.ConfigKeyOutputVariables, shared.ConfigTemplateVariablesDefault)
|
||||
}
|
||||
123
config/loader_test.go
Normal file
123
config/loader_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFileSizeLimit = 5242880
|
||||
testFileSizeLimit = 123456
|
||||
)
|
||||
|
||||
// TestDefaultConfig verifies that if no config file is found,
|
||||
// the default configuration values are correctly set.
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
// Create a temporary directory to ensure no config file is present.
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Point Viper to the temp directory with no config file.
|
||||
originalConfigPaths := viper.ConfigFileUsed()
|
||||
testutil.ResetViperConfig(t, tmpDir)
|
||||
|
||||
// Check defaults
|
||||
defaultSizeLimit := config.FileSizeLimit()
|
||||
if defaultSizeLimit != defaultFileSizeLimit {
|
||||
t.Errorf("Expected default file size limit of 5242880, got %d", defaultSizeLimit)
|
||||
}
|
||||
|
||||
ignoredDirs := config.IgnoredDirectories()
|
||||
if len(ignoredDirs) == 0 {
|
||||
t.Error("Expected some default ignored directories, got none")
|
||||
}
|
||||
|
||||
// Restore Viper state
|
||||
viper.SetConfigFile(originalConfigPaths)
|
||||
}
|
||||
|
||||
// TestLoadConfigFile verifies that when a valid config file is present,
|
||||
// viper loads the specified values correctly.
|
||||
func TestLoadConfigFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Prepare a minimal config file
|
||||
configContent := []byte(`---
|
||||
fileSizeLimit: 123456
|
||||
ignoreDirectories:
|
||||
- "testdir1"
|
||||
- "testdir2"
|
||||
`)
|
||||
|
||||
testutil.CreateTestFile(t, tmpDir, "config.yaml", configContent)
|
||||
|
||||
// Reset viper and point to the new config path
|
||||
viper.Reset()
|
||||
viper.AddConfigPath(tmpDir)
|
||||
|
||||
// Force Viper to read our config file
|
||||
testutil.MustSucceed(t, viper.ReadInConfig(), "reading config file")
|
||||
|
||||
// Validate loaded data
|
||||
if got := viper.GetInt64("fileSizeLimit"); got != testFileSizeLimit {
|
||||
t.Errorf("Expected fileSizeLimit=123456, got %d", got)
|
||||
}
|
||||
|
||||
ignored := viper.GetStringSlice("ignoreDirectories")
|
||||
if len(ignored) != 2 || ignored[0] != "testdir1" || ignored[1] != "testdir2" {
|
||||
t.Errorf("Expected [\"testdir1\", \"testdir2\"], got %v", ignored)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigWithValidation tests that invalid config files fall back to defaults.
|
||||
func TestLoadConfigWithValidation(t *testing.T) {
|
||||
// Create a temporary config file with invalid content
|
||||
configContent := "fileSizeLimit: 100\n" +
|
||||
"ignoreDirectories:\n" +
|
||||
"- node_modules\n" +
|
||||
"- \"\"\n" +
|
||||
"- .git\n"
|
||||
|
||||
tempDir := t.TempDir()
|
||||
configFile := tempDir + "/config.yaml"
|
||||
|
||||
err := os.WriteFile(configFile, []byte(configContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Reset viper and set config path
|
||||
viper.Reset()
|
||||
viper.AddConfigPath(tempDir)
|
||||
|
||||
// This should load the config but validation should fail and fall back to defaults
|
||||
config.LoadConfig()
|
||||
|
||||
// Should have fallen back to defaults due to validation failure
|
||||
if config.FileSizeLimit() != int64(shared.ConfigFileSizeLimitDefault) {
|
||||
t.Errorf("Expected default file size limit after validation failure, got %d", config.FileSizeLimit())
|
||||
}
|
||||
if containsString(config.IgnoredDirectories(), "") {
|
||||
t.Errorf(
|
||||
"Expected ignored directories not to contain empty string after validation failure, got %v",
|
||||
config.IgnoredDirectories(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func containsString(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
620
config/validation.go
Normal file
620
config/validation.go
Normal file
@@ -0,0 +1,620 @@
|
||||
// Package config handles application configuration management.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// ValidateConfig validates the loaded configuration.
|
||||
func ValidateConfig() error {
|
||||
var validationErrors []string
|
||||
|
||||
// Validate basic settings
|
||||
validationErrors = append(validationErrors, validateBasicSettings()...)
|
||||
validationErrors = append(validationErrors, validateFileTypeSettings()...)
|
||||
validationErrors = append(validationErrors, validateBackpressureSettings()...)
|
||||
validationErrors = append(validationErrors, validateResourceLimitSettings()...)
|
||||
|
||||
if len(validationErrors) > 0 {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeConfiguration,
|
||||
shared.CodeConfigValidation,
|
||||
"configuration validation failed: "+strings.Join(validationErrors, "; "),
|
||||
"",
|
||||
map[string]any{"validation_errors": validationErrors},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBasicSettings validates basic configuration settings.
|
||||
func validateBasicSettings() []string {
|
||||
var validationErrors []string
|
||||
|
||||
validationErrors = append(validationErrors, validateFileSizeLimit()...)
|
||||
validationErrors = append(validationErrors, validateIgnoreDirectories()...)
|
||||
validationErrors = append(validationErrors, validateSupportedFormats()...)
|
||||
validationErrors = append(validationErrors, validateConcurrencySettings()...)
|
||||
validationErrors = append(validationErrors, validateFilePatterns()...)
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateFileSizeLimit validates the file size limit setting.
|
||||
func validateFileSizeLimit() []string {
|
||||
var validationErrors []string
|
||||
|
||||
fileSizeLimit := viper.GetInt64(shared.ConfigKeyFileSizeLimit)
|
||||
if fileSizeLimit < shared.ConfigFileSizeLimitMin {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("fileSizeLimit (%d) is below minimum (%d)", fileSizeLimit, shared.ConfigFileSizeLimitMin),
|
||||
)
|
||||
}
|
||||
if fileSizeLimit > shared.ConfigFileSizeLimitMax {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("fileSizeLimit (%d) exceeds maximum (%d)", fileSizeLimit, shared.ConfigFileSizeLimitMax),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateIgnoreDirectories validates the ignore directories setting.
|
||||
func validateIgnoreDirectories() []string {
|
||||
var validationErrors []string
|
||||
|
||||
ignoreDirectories := viper.GetStringSlice(shared.ConfigKeyIgnoreDirectories)
|
||||
for i, dir := range ignoreDirectories {
|
||||
if errMsg := validateEmptyElement(shared.ConfigKeyIgnoreDirectories, dir, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
|
||||
continue
|
||||
}
|
||||
dir = strings.TrimSpace(dir)
|
||||
if strings.Contains(dir, "/") {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf(
|
||||
"ignoreDirectories[%d] (%s) contains path separator - only directory names are allowed", i, dir,
|
||||
),
|
||||
)
|
||||
}
|
||||
if strings.HasPrefix(dir, ".") && dir != ".git" && dir != ".vscode" && dir != ".idea" {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("ignoreDirectories[%d] (%s) starts with dot - this may cause unexpected behavior", i, dir),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateSupportedFormats validates the supported formats setting.
|
||||
func validateSupportedFormats() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeySupportedFormats) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
supportedFormats := viper.GetStringSlice(shared.ConfigKeySupportedFormats)
|
||||
validFormats := map[string]bool{shared.FormatJSON: true, shared.FormatYAML: true, shared.FormatMarkdown: true}
|
||||
for i, format := range supportedFormats {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if !validFormats[format] {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("supportedFormats[%d] (%s) is not a valid format (json, yaml, markdown)", i, format),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateConcurrencySettings validates the concurrency settings.
|
||||
func validateConcurrencySettings() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyMaxConcurrency) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxConcurrency := viper.GetInt(shared.ConfigKeyMaxConcurrency)
|
||||
if maxConcurrency < 1 {
|
||||
validationErrors = append(
|
||||
validationErrors, fmt.Sprintf("maxConcurrency (%d) must be at least 1", maxConcurrency),
|
||||
)
|
||||
}
|
||||
if maxConcurrency > 100 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("maxConcurrency (%d) is unreasonably high (max 100)", maxConcurrency),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateFilePatterns validates the file patterns setting.
|
||||
func validateFilePatterns() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyFilePatterns) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
filePatterns := viper.GetStringSlice(shared.ConfigKeyFilePatterns)
|
||||
for i, pattern := range filePatterns {
|
||||
if errMsg := validateEmptyElement(shared.ConfigKeyFilePatterns, pattern, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
|
||||
continue
|
||||
}
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
// Basic validation - patterns should contain at least one alphanumeric character
|
||||
if !strings.ContainsAny(pattern, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("filePatterns[%d] (%s) appears to be invalid", i, pattern),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateFileTypeSettings validates file type configuration settings.
|
||||
func validateFileTypeSettings() []string {
|
||||
var validationErrors []string
|
||||
|
||||
validationErrors = append(validationErrors, validateCustomImageExtensions()...)
|
||||
validationErrors = append(validationErrors, validateCustomBinaryExtensions()...)
|
||||
validationErrors = append(validationErrors, validateCustomLanguages()...)
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateCustomImageExtensions validates custom image extensions.
|
||||
func validateCustomImageExtensions() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyFileTypesCustomImageExtensions) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
customImages := viper.GetStringSlice(shared.ConfigKeyFileTypesCustomImageExtensions)
|
||||
for i, ext := range customImages {
|
||||
if errMsg := validateEmptyElement(shared.ConfigKeyFileTypesCustomImageExtensions, ext, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
|
||||
continue
|
||||
}
|
||||
ext = strings.TrimSpace(ext)
|
||||
if errMsg := validateDotPrefix(shared.ConfigKeyFileTypesCustomImageExtensions, ext, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateCustomBinaryExtensions validates custom binary extensions.
|
||||
func validateCustomBinaryExtensions() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyFileTypesCustomBinaryExtensions) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
customBinary := viper.GetStringSlice(shared.ConfigKeyFileTypesCustomBinaryExtensions)
|
||||
for i, ext := range customBinary {
|
||||
if errMsg := validateEmptyElement(shared.ConfigKeyFileTypesCustomBinaryExtensions, ext, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
|
||||
continue
|
||||
}
|
||||
ext = strings.TrimSpace(ext)
|
||||
if errMsg := validateDotPrefix(shared.ConfigKeyFileTypesCustomBinaryExtensions, ext, i); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateCustomLanguages validates custom language mappings.
|
||||
func validateCustomLanguages() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyFileTypesCustomLanguages) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
customLangs := viper.GetStringMapString(shared.ConfigKeyFileTypesCustomLanguages)
|
||||
for ext, lang := range customLangs {
|
||||
ext = strings.TrimSpace(ext)
|
||||
if ext == "" {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
shared.ConfigKeyFileTypesCustomLanguages+" contains empty extension key",
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
if errMsg := validateDotPrefixMap(shared.ConfigKeyFileTypesCustomLanguages, ext); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
}
|
||||
if errMsg := validateEmptyMapValue(shared.ConfigKeyFileTypesCustomLanguages, ext, lang); errMsg != "" {
|
||||
validationErrors = append(validationErrors, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateBackpressureSettings validates back-pressure configuration settings.
|
||||
func validateBackpressureSettings() []string {
|
||||
var validationErrors []string
|
||||
|
||||
validationErrors = append(validationErrors, validateMaxPendingFiles()...)
|
||||
validationErrors = append(validationErrors, validateMaxPendingWrites()...)
|
||||
validationErrors = append(validationErrors, validateMaxMemoryUsage()...)
|
||||
validationErrors = append(validationErrors, validateMemoryCheckInterval()...)
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMaxPendingFiles validates backpressure.maxPendingFiles setting.
|
||||
func validateMaxPendingFiles() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyBackpressureMaxPendingFiles) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxPendingFiles := viper.GetInt(shared.ConfigKeyBackpressureMaxPendingFiles)
|
||||
if maxPendingFiles < 1 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxPendingFiles (%d) must be at least 1", maxPendingFiles),
|
||||
)
|
||||
}
|
||||
if maxPendingFiles > 100000 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxPendingFiles (%d) is unreasonably high (max 100000)", maxPendingFiles),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMaxPendingWrites validates backpressure.maxPendingWrites setting.
|
||||
func validateMaxPendingWrites() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyBackpressureMaxPendingWrites) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxPendingWrites := viper.GetInt(shared.ConfigKeyBackpressureMaxPendingWrites)
|
||||
if maxPendingWrites < 1 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxPendingWrites (%d) must be at least 1", maxPendingWrites),
|
||||
)
|
||||
}
|
||||
if maxPendingWrites > 10000 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxPendingWrites (%d) is unreasonably high (max 10000)", maxPendingWrites),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMaxMemoryUsage validates backpressure.maxMemoryUsage setting.
|
||||
func validateMaxMemoryUsage() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyBackpressureMaxMemoryUsage) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxMemoryUsage := viper.GetInt64(shared.ConfigKeyBackpressureMaxMemoryUsage)
|
||||
minMemory := int64(shared.BytesPerMB) // 1MB minimum
|
||||
maxMemory := int64(10 * shared.BytesPerGB) // 10GB maximum
|
||||
if maxMemoryUsage < minMemory {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxMemoryUsage (%d) must be at least 1MB (%d bytes)", maxMemoryUsage, minMemory),
|
||||
)
|
||||
}
|
||||
if maxMemoryUsage > maxMemory { // 10GB maximum
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.maxMemoryUsage (%d) is unreasonably high (max 10GB)", maxMemoryUsage),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMemoryCheckInterval validates backpressure.memoryCheckInterval setting.
|
||||
func validateMemoryCheckInterval() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyBackpressureMemoryCheckInt) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
interval := viper.GetInt(shared.ConfigKeyBackpressureMemoryCheckInt)
|
||||
if interval < 1 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.memoryCheckInterval (%d) must be at least 1", interval),
|
||||
)
|
||||
}
|
||||
if interval > 100000 {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("backpressure.memoryCheckInterval (%d) is unreasonably high (max 100000)", interval),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateResourceLimitSettings validates resource limit configuration settings.
|
||||
func validateResourceLimitSettings() []string {
|
||||
var validationErrors []string
|
||||
|
||||
validationErrors = append(validationErrors, validateMaxFilesLimit()...)
|
||||
validationErrors = append(validationErrors, validateMaxTotalSizeLimit()...)
|
||||
validationErrors = append(validationErrors, validateTimeoutLimits()...)
|
||||
validationErrors = append(validationErrors, validateConcurrencyLimits()...)
|
||||
validationErrors = append(validationErrors, validateMemoryLimits()...)
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMaxFilesLimit validates resourceLimits.maxFiles setting.
|
||||
func validateMaxFilesLimit() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyResourceLimitsMaxFiles) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxFiles := viper.GetInt(shared.ConfigKeyResourceLimitsMaxFiles)
|
||||
if maxFiles < shared.ConfigMaxFilesMin {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxFiles (%d) must be at least %d", maxFiles, shared.ConfigMaxFilesMin),
|
||||
)
|
||||
}
|
||||
if maxFiles > shared.ConfigMaxFilesMax {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxFiles (%d) exceeds maximum (%d)", maxFiles, shared.ConfigMaxFilesMax),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMaxTotalSizeLimit validates resourceLimits.maxTotalSize setting.
|
||||
func validateMaxTotalSizeLimit() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyResourceLimitsMaxTotalSize) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
maxTotalSize := viper.GetInt64(shared.ConfigKeyResourceLimitsMaxTotalSize)
|
||||
minTotalSize := int64(shared.ConfigMaxTotalSizeMin)
|
||||
maxTotalSizeLimit := int64(shared.ConfigMaxTotalSizeMax)
|
||||
if maxTotalSize < minTotalSize {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxTotalSize (%d) must be at least %d", maxTotalSize, minTotalSize),
|
||||
)
|
||||
}
|
||||
if maxTotalSize > maxTotalSizeLimit {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxTotalSize (%d) exceeds maximum (%d)", maxTotalSize, maxTotalSizeLimit),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateTimeoutLimits validates timeout-related resource limit settings.
|
||||
func validateTimeoutLimits() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if viper.IsSet(shared.ConfigKeyResourceLimitsFileProcessingTO) {
|
||||
timeout := viper.GetInt(shared.ConfigKeyResourceLimitsFileProcessingTO)
|
||||
if timeout < shared.ConfigFileProcessingTimeoutSecMin {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf(
|
||||
"resourceLimits.fileProcessingTimeoutSec (%d) must be at least %d",
|
||||
timeout,
|
||||
shared.ConfigFileProcessingTimeoutSecMin,
|
||||
),
|
||||
)
|
||||
}
|
||||
if timeout > shared.ConfigFileProcessingTimeoutSecMax {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf(
|
||||
"resourceLimits.fileProcessingTimeoutSec (%d) exceeds maximum (%d)",
|
||||
timeout,
|
||||
shared.ConfigFileProcessingTimeoutSecMax,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if viper.IsSet(shared.ConfigKeyResourceLimitsOverallTO) {
|
||||
timeout := viper.GetInt(shared.ConfigKeyResourceLimitsOverallTO)
|
||||
minTimeout := shared.ConfigOverallTimeoutSecMin
|
||||
maxTimeout := shared.ConfigOverallTimeoutSecMax
|
||||
if timeout < minTimeout {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.overallTimeoutSec (%d) must be at least %d", timeout, minTimeout),
|
||||
)
|
||||
}
|
||||
if timeout > maxTimeout {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.overallTimeoutSec (%d) exceeds maximum (%d)", timeout, maxTimeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateConcurrencyLimits validates concurrency-related resource limit settings.
|
||||
func validateConcurrencyLimits() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if viper.IsSet(shared.ConfigKeyResourceLimitsMaxConcurrentReads) {
|
||||
maxReads := viper.GetInt(shared.ConfigKeyResourceLimitsMaxConcurrentReads)
|
||||
minReads := shared.ConfigMaxConcurrentReadsMin
|
||||
maxReadsLimit := shared.ConfigMaxConcurrentReadsMax
|
||||
if maxReads < minReads {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxConcurrentReads (%d) must be at least %d", maxReads, minReads),
|
||||
)
|
||||
}
|
||||
if maxReads > maxReadsLimit {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.maxConcurrentReads (%d) exceeds maximum (%d)", maxReads, maxReadsLimit),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if viper.IsSet(shared.ConfigKeyResourceLimitsRateLimitFilesPerSec) {
|
||||
rateLimit := viper.GetInt(shared.ConfigKeyResourceLimitsRateLimitFilesPerSec)
|
||||
minRate := shared.ConfigRateLimitFilesPerSecMin
|
||||
maxRate := shared.ConfigRateLimitFilesPerSecMax
|
||||
if rateLimit < minRate {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.rateLimitFilesPerSec (%d) must be at least %d", rateLimit, minRate),
|
||||
)
|
||||
}
|
||||
if rateLimit > maxRate {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.rateLimitFilesPerSec (%d) exceeds maximum (%d)", rateLimit, maxRate),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// validateMemoryLimits validates memory-related resource limit settings.
|
||||
func validateMemoryLimits() []string {
|
||||
var validationErrors []string
|
||||
|
||||
if !viper.IsSet(shared.ConfigKeyResourceLimitsHardMemoryLimitMB) {
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
memLimit := viper.GetInt(shared.ConfigKeyResourceLimitsHardMemoryLimitMB)
|
||||
minMemLimit := shared.ConfigHardMemoryLimitMBMin
|
||||
maxMemLimit := shared.ConfigHardMemoryLimitMBMax
|
||||
if memLimit < minMemLimit {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.hardMemoryLimitMB (%d) must be at least %d", memLimit, minMemLimit),
|
||||
)
|
||||
}
|
||||
if memLimit > maxMemLimit {
|
||||
validationErrors = append(
|
||||
validationErrors,
|
||||
fmt.Sprintf("resourceLimits.hardMemoryLimitMB (%d) exceeds maximum (%d)", memLimit, maxMemLimit),
|
||||
)
|
||||
}
|
||||
|
||||
return validationErrors
|
||||
}
|
||||
|
||||
// ValidateFileSize checks if a file size is within the configured limit.
|
||||
func ValidateFileSize(size int64) error {
|
||||
limit := FileSizeLimit()
|
||||
if size > limit {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationSize,
|
||||
fmt.Sprintf(shared.FileProcessingMsgSizeExceeds, size, limit),
|
||||
"",
|
||||
map[string]any{"file_size": size, "size_limit": limit},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOutputFormat checks if an output format is valid.
|
||||
func ValidateOutputFormat(format string) error {
|
||||
if !IsValidFormat(format) {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationFormat,
|
||||
fmt.Sprintf("unsupported output format: %s (supported: json, yaml, markdown)", format),
|
||||
"",
|
||||
map[string]any{"format": format},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateConcurrency checks if a concurrency level is valid.
|
||||
func ValidateConcurrency(concurrency int) error {
|
||||
if concurrency < 1 {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationFormat,
|
||||
fmt.Sprintf("concurrency (%d) must be at least 1", concurrency),
|
||||
"",
|
||||
map[string]any{"concurrency": concurrency},
|
||||
)
|
||||
}
|
||||
|
||||
if viper.IsSet(shared.ConfigKeyMaxConcurrency) {
|
||||
maxConcurrency := MaxConcurrency()
|
||||
if concurrency > maxConcurrency {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationFormat,
|
||||
fmt.Sprintf("concurrency (%d) exceeds maximum (%d)", concurrency, maxConcurrency),
|
||||
"",
|
||||
map[string]any{"concurrency": concurrency, "max_concurrency": maxConcurrency},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
51
config/validation_helpers.go
Normal file
51
config/validation_helpers.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package config handles application configuration management.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validateEmptyElement checks if an element in a slice is empty after trimming whitespace.
|
||||
// Returns a formatted error message if empty, or empty string if valid.
|
||||
func validateEmptyElement(fieldPath, value string, index int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fmt.Sprintf("%s[%d] is empty", fieldPath, index)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateDotPrefix ensures an extension starts with a dot.
|
||||
// Returns a formatted error message if missing dot prefix, or empty string if valid.
|
||||
func validateDotPrefix(fieldPath, value string, index int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(value, ".") {
|
||||
return fmt.Sprintf("%s[%d] (%s) must start with a dot", fieldPath, index, value)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateDotPrefixMap ensures a map key (extension) starts with a dot.
|
||||
// Returns a formatted error message if missing dot prefix, or empty string if valid.
|
||||
func validateDotPrefixMap(fieldPath, key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if !strings.HasPrefix(key, ".") {
|
||||
return fmt.Sprintf("%s extension (%s) must start with a dot", fieldPath, key)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// validateEmptyMapValue checks if a map value is empty after trimming whitespace.
|
||||
// Returns a formatted error message if empty, or empty string if valid.
|
||||
func validateEmptyMapValue(fieldPath, key, value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fmt.Sprintf("%s[%s] has empty language value", fieldPath, key)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
258
config/validation_test.go
Normal file
258
config/validation_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// TestValidateConfig tests the configuration validation functionality.
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "valid default config",
|
||||
config: map[string]any{
|
||||
"fileSizeLimit": shared.ConfigFileSizeLimitDefault,
|
||||
"ignoreDirectories": []string{"node_modules", ".git"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "file size limit too small",
|
||||
config: map[string]any{
|
||||
"fileSizeLimit": shared.ConfigFileSizeLimitMin - 1,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "fileSizeLimit",
|
||||
},
|
||||
{
|
||||
name: "file size limit too large",
|
||||
config: map[string]any{
|
||||
"fileSizeLimit": shared.ConfigFileSizeLimitMax + 1,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "fileSizeLimit",
|
||||
},
|
||||
{
|
||||
name: "empty ignore directory",
|
||||
config: map[string]any{
|
||||
"ignoreDirectories": []string{"node_modules", "", ".git"},
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "ignoreDirectories",
|
||||
},
|
||||
{
|
||||
name: "ignore directory with path separator",
|
||||
config: map[string]any{
|
||||
"ignoreDirectories": []string{"node_modules", "src/build", ".git"},
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "path separator",
|
||||
},
|
||||
{
|
||||
name: "invalid supported format",
|
||||
config: map[string]any{
|
||||
"supportedFormats": []string{"json", "xml", "yaml"},
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "not a valid format",
|
||||
},
|
||||
{
|
||||
name: "invalid max concurrency",
|
||||
config: map[string]any{
|
||||
"maxConcurrency": 0,
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "maxConcurrency",
|
||||
},
|
||||
{
|
||||
name: "valid comprehensive config",
|
||||
config: map[string]any{
|
||||
"fileSizeLimit": shared.ConfigFileSizeLimitDefault,
|
||||
"ignoreDirectories": []string{"node_modules", ".git", ".vscode"},
|
||||
"supportedFormats": []string{"json", "yaml", "markdown"},
|
||||
"maxConcurrency": 8,
|
||||
"filePatterns": []string{"*.go", "*.js", "*.py"},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name, func(t *testing.T) {
|
||||
// Reset viper for each test
|
||||
viper.Reset()
|
||||
|
||||
// Set test configuration
|
||||
for key, value := range tt.config {
|
||||
viper.Set(key, value)
|
||||
}
|
||||
|
||||
// Set defaults for missing values without touching disk
|
||||
config.SetDefaultConfig()
|
||||
|
||||
err := config.ValidateConfig()
|
||||
|
||||
if tt.wantErr {
|
||||
validateExpectedError(t, err, tt.errContains)
|
||||
} else if err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsValidFormat tests the IsValidFormat function.
|
||||
func TestIsValidFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
valid bool
|
||||
}{
|
||||
{"json", true},
|
||||
{"yaml", true},
|
||||
{"markdown", true},
|
||||
{"JSON", true},
|
||||
{"xml", false},
|
||||
{"txt", false},
|
||||
{"", false},
|
||||
{" json ", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := config.IsValidFormat(tt.format)
|
||||
if result != tt.valid {
|
||||
t.Errorf("IsValidFormat(%q) = %v, want %v", tt.format, result, tt.valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFileSize tests the ValidateFileSize function.
|
||||
func TestValidateFileSize(t *testing.T) {
|
||||
viper.Reset()
|
||||
viper.Set("fileSizeLimit", shared.ConfigFileSizeLimitDefault)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
size int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"size within limit", shared.ConfigFileSizeLimitDefault - 1, false},
|
||||
{"size at limit", shared.ConfigFileSizeLimitDefault, false},
|
||||
{"size exceeds limit", shared.ConfigFileSizeLimitDefault + 1, true},
|
||||
{"zero size", 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
err := config.ValidateFileSize(tt.size)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: ValidateFileSize(%d) error = %v, wantErr %v", tt.name, tt.size, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateOutputFormat tests the ValidateOutputFormat function.
|
||||
func TestValidateOutputFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{"json", false},
|
||||
{"yaml", false},
|
||||
{"markdown", false},
|
||||
{"xml", true},
|
||||
{"txt", true},
|
||||
{"", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
err := config.ValidateOutputFormat(tt.format)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateOutputFormat(%q) error = %v, wantErr %v", tt.format, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConcurrency tests the ValidateConcurrency function.
|
||||
func TestValidateConcurrency(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
concurrency int
|
||||
maxConcurrency int
|
||||
setMax bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid concurrency", 4, 0, false, false},
|
||||
{"minimum concurrency", 1, 0, false, false},
|
||||
{"zero concurrency", 0, 0, false, true},
|
||||
{"negative concurrency", -1, 0, false, true},
|
||||
{"concurrency within max", 4, 8, true, false},
|
||||
{"concurrency exceeds max", 16, 8, true, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
viper.Reset()
|
||||
if tt.setMax {
|
||||
viper.Set("maxConcurrency", tt.maxConcurrency)
|
||||
}
|
||||
|
||||
err := config.ValidateConcurrency(tt.concurrency)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: ValidateConcurrency(%d) error = %v, wantErr %v", tt.name, tt.concurrency, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateExpectedError validates that an error occurred and matches expectations.
|
||||
func validateExpectedError(t *testing.T, err error, errContains string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Error(shared.TestMsgExpectedError)
|
||||
|
||||
return
|
||||
}
|
||||
if errContains != "" && !strings.Contains(err.Error(), errContains) {
|
||||
t.Errorf("Expected error to contain %q, got %q", errContains, err.Error())
|
||||
}
|
||||
|
||||
// Check that it's a structured error
|
||||
var structErr *shared.StructuredError
|
||||
if !errorAs(err, &structErr) {
|
||||
t.Errorf("Expected structured error, got %T", err)
|
||||
|
||||
return
|
||||
}
|
||||
if structErr.Type != shared.ErrorTypeConfiguration {
|
||||
t.Errorf("Expected error type %v, got %v", shared.ErrorTypeConfiguration, structErr.Type)
|
||||
}
|
||||
if structErr.Code != shared.CodeConfigValidation {
|
||||
t.Errorf("Expected error code %v, got %v", shared.CodeConfigValidation, structErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func errorAs(err error, target any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
if ptr, ok := target.(**shared.StructuredError); ok {
|
||||
*ptr = structErr
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Replace check_secrets with gitleaks
|
||||
|
||||
## Problem
|
||||
|
||||
The `check_secrets` function in `scripts/security-scan.sh` uses hand-rolled regex
|
||||
patterns that produce false positives. The pattern `key\s*[:=]\s*['"][^'"]{8,}['"]`
|
||||
matches every `configKey: "backpressure.maxPendingFiles"` line in
|
||||
`config/getters_test.go` (40+ matches), causing `make security-full` to fail.
|
||||
|
||||
The git history check (`git log --oneline -10 | grep -i "key|token"`) also matches
|
||||
on benign commit messages containing words like "key" or "token".
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the custom `check_secrets` function with
|
||||
[gitleaks](https://github.com/gitleaks/gitleaks), a widely adopted Go-based secret
|
||||
scanner with built-in rules for AWS keys, GitHub tokens, private keys, high-entropy
|
||||
strings, and more.
|
||||
|
||||
## Approach
|
||||
|
||||
- **Drop-in replacement**: Only the `check_secrets` function body changes. The
|
||||
function signature and return behavior (0 = clean, 1 = findings) remain identical.
|
||||
- **`go run` invocation**: Use `go run github.com/gitleaks/gitleaks/v8@latest` so
|
||||
the tool is fetched automatically if not cached. No changes to `install-tools.sh`.
|
||||
- **Working tree scan only**: Use `gitleaks dir` to scan current files. No git
|
||||
history scanning (matches current script behavior scope).
|
||||
- **Config file**: A `.gitleaks.toml` at the project root extends gitleaks' built-in
|
||||
rules with an allowlist to suppress known false positives in test files.
|
||||
- **CI unaffected**: `.github/workflows/security.yml` runs its own inline steps
|
||||
(gosec, govulncheck, checkmake, shfmt, yamllint, Trivy) and does not call
|
||||
`security-scan.sh` or `check_secrets`.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `scripts/security-scan.sh` | Replace `check_secrets` function body |
|
||||
| `.gitleaks.toml` | New file -- gitleaks configuration with allowlist |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make security-full # should pass end-to-end
|
||||
```
|
||||
219
examples/basic-usage.md
Normal file
219
examples/basic-usage.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Basic Usage Examples
|
||||
|
||||
This directory contains practical examples of how to use gibidify for various use cases.
|
||||
|
||||
## Simple Code Aggregation
|
||||
|
||||
The most basic use case - aggregate all code files from a project into a single output:
|
||||
|
||||
```bash
|
||||
# Aggregate all files from current directory to markdown
|
||||
gibidify -source . -format markdown -destination output.md
|
||||
|
||||
# Aggregate specific directory to JSON
|
||||
gibidify -source ./src -format json -destination code-dump.json
|
||||
|
||||
# Aggregate with custom worker count
|
||||
gibidify -source ./project -format yaml -destination project.yaml -concurrency 8
|
||||
```
|
||||
|
||||
## With Configuration File
|
||||
|
||||
For repeatable processing with custom settings:
|
||||
|
||||
1. Copy the configuration example:
|
||||
```bash
|
||||
cp config.example.yaml ~/.config/gibidify/config.yaml
|
||||
```
|
||||
|
||||
2. Edit the configuration file to your needs, then run:
|
||||
```bash
|
||||
gibidify -source ./my-project
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
### JSON Output
|
||||
Best for programmatic processing and data analysis:
|
||||
|
||||
```bash
|
||||
gibidify -source ./src -format json -destination api-code.json
|
||||
```
|
||||
|
||||
Example JSON structure:
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "src/main.go",
|
||||
"content": "package main...",
|
||||
"language": "go",
|
||||
"size": 1024
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"total_files": 15,
|
||||
"total_size": 45678,
|
||||
"processing_time": "1.2s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown Output
|
||||
Great for documentation and code reviews:
|
||||
|
||||
```bash
|
||||
gibidify -source ./src -format markdown -destination code-review.md
|
||||
```
|
||||
|
||||
### YAML Output
|
||||
Structured and human-readable:
|
||||
|
||||
```bash
|
||||
gibidify -source ./config -format yaml -destination config-dump.yaml
|
||||
```
|
||||
|
||||
## Advanced Usage Examples
|
||||
|
||||
### Large Codebase Processing
|
||||
For processing large projects with performance optimizations:
|
||||
|
||||
```bash
|
||||
gibidify -source ./large-project \
|
||||
-format json \
|
||||
-destination large-output.json \
|
||||
-concurrency 16 \
|
||||
--verbose
|
||||
```
|
||||
|
||||
### Memory-Conscious Processing
|
||||
For systems with limited memory:
|
||||
|
||||
```bash
|
||||
gibidify -source ./project \
|
||||
-format markdown \
|
||||
-destination output.md \
|
||||
-concurrency 4
|
||||
```
|
||||
|
||||
### Filtered Processing
|
||||
Process only specific file types (when configured):
|
||||
|
||||
```bash
|
||||
# Configure file patterns in config.yaml
|
||||
filePatterns:
|
||||
- "*.go"
|
||||
- "*.py"
|
||||
- "*.js"
|
||||
|
||||
# Then run
|
||||
gibidify -source ./mixed-project -destination filtered.json
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
For automated documentation generation:
|
||||
|
||||
```bash
|
||||
# In your CI pipeline
|
||||
gibidify -source . \
|
||||
-format markdown \
|
||||
-destination docs/codebase.md \
|
||||
--no-colors \
|
||||
--no-progress \
|
||||
-concurrency 2
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Failure Handling
|
||||
The tool handles common issues gracefully:
|
||||
|
||||
```bash
|
||||
# This will fail gracefully if source doesn't exist
|
||||
gibidify -source ./nonexistent -destination out.json
|
||||
|
||||
# This will warn about permission issues but continue
|
||||
gibidify -source ./restricted-dir -destination out.md --verbose
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
Configure resource limits in your config file:
|
||||
|
||||
```yaml
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
maxFiles: 5000
|
||||
maxTotalSize: 1073741824 # 1GB
|
||||
fileProcessingTimeoutSec: 30
|
||||
overallTimeoutSec: 1800 # 30 minutes
|
||||
hardMemoryLimitMB: 512
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Adjust Concurrency**: Start with number of CPU cores, adjust based on I/O vs CPU bound work
|
||||
2. **Use Appropriate Format**: JSON is fastest, Markdown has more overhead
|
||||
3. **Configure File Limits**: Set reasonable limits in config.yaml for your use case
|
||||
4. **Monitor Memory**: Use `--verbose` to see memory usage during processing
|
||||
5. **Use Progress Indicators**: Enable progress bars for long-running operations
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### With Git Hooks
|
||||
Create a pre-commit hook to generate code documentation:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# .git/hooks/pre-commit
|
||||
gibidify -source . -format markdown -destination docs/current-code.md
|
||||
git add docs/current-code.md
|
||||
```
|
||||
|
||||
### With Make
|
||||
Add to your Makefile:
|
||||
|
||||
```makefile
|
||||
.PHONY: code-dump
|
||||
code-dump:
|
||||
gibidify -source ./src -format json -destination dist/codebase.json
|
||||
|
||||
.PHONY: docs
|
||||
docs:
|
||||
gibidify -source . -format markdown -destination docs/codebase.md
|
||||
```
|
||||
|
||||
### Docker Usage
|
||||
```dockerfile
|
||||
FROM golang:1.25-alpine
|
||||
RUN go install github.com/ivuorinen/gibidify@latest
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN gibidify -source . -format json -destination /output/codebase.json
|
||||
```
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
### 1. Code Review Preparation
|
||||
```bash
|
||||
gibidify -source ./feature-branch -format markdown -destination review.md
|
||||
```
|
||||
|
||||
### 2. AI Code Analysis
|
||||
```bash
|
||||
gibidify -source ./src -format json -destination ai-input.json
|
||||
```
|
||||
|
||||
### 3. Documentation Generation
|
||||
```bash
|
||||
gibidify -source ./lib -format markdown -destination api-docs.md
|
||||
```
|
||||
|
||||
### 4. Backup Creation
|
||||
```bash
|
||||
gibidify -source ./project -format yaml -destination backup-$(date +%Y%m%d).yaml
|
||||
```
|
||||
|
||||
### 5. Code Migration Prep
|
||||
```bash
|
||||
gibidify -source ./legacy-code -format json -destination migration-analysis.json
|
||||
```
|
||||
469
examples/configuration-examples.md
Normal file
469
examples/configuration-examples.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Configuration Examples
|
||||
|
||||
This document provides practical configuration examples for different use cases.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Create `~/.config/gibidify/config.yaml`:
|
||||
|
||||
```yaml
|
||||
# Basic setup for most projects
|
||||
fileSizeLimit: 5242880 # 5MB per file
|
||||
maxConcurrency: 8
|
||||
|
||||
ignoreDirectories:
|
||||
- vendor
|
||||
- node_modules
|
||||
- .git
|
||||
- dist
|
||||
- target
|
||||
|
||||
# Enable file type detection
|
||||
fileTypes:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
## Development Environment Configuration
|
||||
|
||||
Optimized for active development with fast feedback:
|
||||
|
||||
```yaml
|
||||
# ~/.config/gibidify/config.yaml
|
||||
fileSizeLimit: 1048576 # 1MB - smaller files for faster processing
|
||||
|
||||
ignoreDirectories:
|
||||
- vendor
|
||||
- node_modules
|
||||
- .git
|
||||
- dist
|
||||
- build
|
||||
- tmp
|
||||
- cache
|
||||
- .vscode
|
||||
- .idea
|
||||
|
||||
# Conservative resource limits for development
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
maxFiles: 1000
|
||||
maxTotalSize: 104857600 # 100MB
|
||||
fileProcessingTimeoutSec: 10
|
||||
overallTimeoutSec: 300 # 5 minutes
|
||||
maxConcurrentReads: 4
|
||||
hardMemoryLimitMB: 256
|
||||
|
||||
# Fast backpressure for responsive development
|
||||
backpressure:
|
||||
enabled: true
|
||||
maxPendingFiles: 500
|
||||
maxPendingWrites: 50
|
||||
maxMemoryUsage: 52428800 # 50MB
|
||||
memoryCheckInterval: 100
|
||||
|
||||
# Simple output for quick reviews
|
||||
output:
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeTimestamp: true
|
||||
```
|
||||
|
||||
## Production/CI Configuration
|
||||
|
||||
High-performance setup for automated processing:
|
||||
|
||||
```yaml
|
||||
# Production configuration
|
||||
fileSizeLimit: 10485760 # 10MB per file
|
||||
maxConcurrency: 16
|
||||
|
||||
ignoreDirectories:
|
||||
- vendor
|
||||
- node_modules
|
||||
- .git
|
||||
- dist
|
||||
- build
|
||||
- target
|
||||
- tmp
|
||||
- cache
|
||||
- coverage
|
||||
- .nyc_output
|
||||
- __pycache__
|
||||
|
||||
# High-performance resource limits
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
maxFiles: 50000
|
||||
maxTotalSize: 10737418240 # 10GB
|
||||
fileProcessingTimeoutSec: 60
|
||||
overallTimeoutSec: 7200 # 2 hours
|
||||
maxConcurrentReads: 20
|
||||
hardMemoryLimitMB: 2048
|
||||
|
||||
# High-throughput backpressure
|
||||
backpressure:
|
||||
enabled: true
|
||||
maxPendingFiles: 5000
|
||||
maxPendingWrites: 500
|
||||
maxMemoryUsage: 1073741824 # 1GB
|
||||
memoryCheckInterval: 1000
|
||||
|
||||
# Comprehensive output for analysis
|
||||
output:
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeTimestamp: true
|
||||
includeFileCount: true
|
||||
includeSourcePath: true
|
||||
includeFileTypes: true
|
||||
includeProcessingTime: true
|
||||
includeTotalSize: true
|
||||
includeMetrics: true
|
||||
```
|
||||
|
||||
## Security-Focused Configuration
|
||||
|
||||
Restrictive settings for untrusted input:
|
||||
|
||||
```yaml
|
||||
# Security-first configuration
|
||||
fileSizeLimit: 1048576 # 1MB maximum
|
||||
|
||||
ignoreDirectories:
|
||||
- "**/.*" # All hidden directories
|
||||
- vendor
|
||||
- node_modules
|
||||
- tmp
|
||||
- temp
|
||||
- cache
|
||||
|
||||
# Strict resource limits
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
maxFiles: 100 # Very restrictive
|
||||
maxTotalSize: 10485760 # 10MB total
|
||||
fileProcessingTimeoutSec: 5
|
||||
overallTimeoutSec: 60 # 1 minute max
|
||||
maxConcurrentReads: 2
|
||||
rateLimitFilesPerSec: 10 # Rate limiting enabled
|
||||
hardMemoryLimitMB: 128 # Low memory limit
|
||||
|
||||
# Conservative backpressure
|
||||
backpressure:
|
||||
enabled: true
|
||||
maxPendingFiles: 50
|
||||
maxPendingWrites: 10
|
||||
maxMemoryUsage: 10485760 # 10MB
|
||||
memoryCheckInterval: 10 # Frequent checks
|
||||
|
||||
# Minimal file type detection
|
||||
fileTypes:
|
||||
enabled: true
|
||||
# Disable potentially risky file types
|
||||
disabledLanguageExtensions:
|
||||
- .bat
|
||||
- .cmd
|
||||
- .ps1
|
||||
- .sh
|
||||
disabledBinaryExtensions:
|
||||
- .exe
|
||||
- .dll
|
||||
- .so
|
||||
```
|
||||
|
||||
## Language-Specific Configuration
|
||||
|
||||
### Go Projects
|
||||
```yaml
|
||||
fileSizeLimit: 5242880
|
||||
|
||||
ignoreDirectories:
|
||||
- vendor
|
||||
- .git
|
||||
- bin
|
||||
- pkg
|
||||
|
||||
fileTypes:
|
||||
enabled: true
|
||||
customLanguages:
|
||||
.mod: go-mod
|
||||
.sum: go-sum
|
||||
|
||||
filePatterns:
|
||||
- "*.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "*.md"
|
||||
```
|
||||
|
||||
### JavaScript/Node.js Projects
|
||||
```yaml
|
||||
fileSizeLimit: 2097152 # 2MB
|
||||
|
||||
ignoreDirectories:
|
||||
- node_modules
|
||||
- .git
|
||||
- dist
|
||||
- build
|
||||
- coverage
|
||||
- .nyc_output
|
||||
|
||||
fileTypes:
|
||||
enabled: true
|
||||
customLanguages:
|
||||
.vue: vue
|
||||
.svelte: svelte
|
||||
.astro: astro
|
||||
|
||||
filePatterns:
|
||||
- "*.js"
|
||||
- "*.ts"
|
||||
- "*.jsx"
|
||||
- "*.tsx"
|
||||
- "*.vue"
|
||||
- "*.json"
|
||||
- "*.md"
|
||||
```
|
||||
|
||||
### Python Projects
|
||||
```yaml
|
||||
fileSizeLimit: 5242880
|
||||
|
||||
ignoreDirectories:
|
||||
- .git
|
||||
- __pycache__
|
||||
- .pytest_cache
|
||||
- venv
|
||||
- env
|
||||
- .env
|
||||
- dist
|
||||
- build
|
||||
- .tox
|
||||
|
||||
fileTypes:
|
||||
enabled: true
|
||||
customLanguages:
|
||||
.pyi: python-interface
|
||||
.ipynb: jupyter-notebook
|
||||
|
||||
filePatterns:
|
||||
- "*.py"
|
||||
- "*.pyi"
|
||||
- "requirements*.txt"
|
||||
- "*.toml"
|
||||
- "*.cfg"
|
||||
- "*.ini"
|
||||
- "*.md"
|
||||
```
|
||||
|
||||
## Output Format Configurations
|
||||
|
||||
### Detailed Markdown Output
|
||||
```yaml
|
||||
output:
|
||||
template: "detailed"
|
||||
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeTimestamp: true
|
||||
includeFileCount: true
|
||||
includeSourcePath: true
|
||||
includeFileTypes: true
|
||||
includeProcessingTime: true
|
||||
|
||||
markdown:
|
||||
useCodeBlocks: true
|
||||
includeLanguage: true
|
||||
headerLevel: 2
|
||||
tableOfContents: true
|
||||
syntaxHighlighting: true
|
||||
lineNumbers: true
|
||||
maxLineLength: 120
|
||||
|
||||
variables:
|
||||
project_name: "My Project"
|
||||
author: "Development Team"
|
||||
version: "1.0.0"
|
||||
```
|
||||
|
||||
### Compact JSON Output
|
||||
```yaml
|
||||
output:
|
||||
template: "minimal"
|
||||
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeFileCount: true
|
||||
```
|
||||
|
||||
### Custom Template Output
|
||||
```yaml
|
||||
output:
|
||||
template: "custom"
|
||||
|
||||
custom:
|
||||
header: |
|
||||
# {{ .ProjectName }} Code Dump
|
||||
Generated: {{ .Timestamp }}
|
||||
Total Files: {{ .FileCount }}
|
||||
|
||||
footer: |
|
||||
---
|
||||
Processing completed in {{ .ProcessingTime }}
|
||||
|
||||
fileHeader: |
|
||||
## {{ .Path }}
|
||||
Language: {{ .Language }} | Size: {{ .Size }} bytes
|
||||
|
||||
fileFooter: ""
|
||||
|
||||
variables:
|
||||
project_name: "Custom Project"
|
||||
```
|
||||
|
||||
## Environment-Specific Configurations
|
||||
|
||||
### Docker Container
|
||||
```yaml
|
||||
# Optimized for containerized environments
|
||||
fileSizeLimit: 5242880
|
||||
maxConcurrency: 4 # Conservative for containers
|
||||
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
hardMemoryLimitMB: 512
|
||||
maxFiles: 5000
|
||||
overallTimeoutSec: 1800
|
||||
|
||||
backpressure:
|
||||
enabled: true
|
||||
maxMemoryUsage: 268435456 # 256MB
|
||||
```
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
# CI/CD optimized configuration
|
||||
fileSizeLimit: 2097152
|
||||
maxConcurrency: 2 # Conservative for shared runners
|
||||
|
||||
ignoreDirectories:
|
||||
- .git
|
||||
- .github
|
||||
- node_modules
|
||||
- vendor
|
||||
- dist
|
||||
- build
|
||||
|
||||
resourceLimits:
|
||||
enabled: true
|
||||
maxFiles: 2000
|
||||
overallTimeoutSec: 900 # 15 minutes
|
||||
hardMemoryLimitMB: 1024
|
||||
```
|
||||
|
||||
### Local Development
|
||||
```yaml
|
||||
# Developer-friendly settings
|
||||
fileSizeLimit: 10485760 # 10MB
|
||||
maxConcurrency: 8
|
||||
|
||||
# Show progress and verbose output
|
||||
output:
|
||||
metadata:
|
||||
includeStats: true
|
||||
includeTimestamp: true
|
||||
includeProcessingTime: true
|
||||
includeMetrics: true
|
||||
|
||||
markdown:
|
||||
useCodeBlocks: true
|
||||
includeLanguage: true
|
||||
syntaxHighlighting: true
|
||||
```
|
||||
|
||||
## Template Examples
|
||||
|
||||
### Custom API Documentation Template
|
||||
```yaml
|
||||
output:
|
||||
template: "custom"
|
||||
|
||||
custom:
|
||||
header: |
|
||||
# {{ .Variables.api_name }} API Documentation
|
||||
Version: {{ .Variables.version }}
|
||||
Generated: {{ .Timestamp }}
|
||||
|
||||
## Overview
|
||||
This document contains the complete source code for the {{ .Variables.api_name }} API.
|
||||
|
||||
## Statistics
|
||||
- Total Files: {{ .FileCount }}
|
||||
- Total Size: {{ .TotalSize | formatSize }}
|
||||
- Processing Time: {{ .ProcessingTime }}
|
||||
|
||||
---
|
||||
|
||||
fileHeader: |
|
||||
### {{ .Path }}
|
||||
|
||||
**Type:** {{ .Language }}
|
||||
**Size:** {{ .Size | formatSize }}
|
||||
|
||||
```{{ .Language }}
|
||||
|
||||
fileFooter: |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
footer: |
|
||||
## Summary
|
||||
|
||||
Documentation generated with [gibidify](https://github.com/ivuorinen/gibidify)
|
||||
|
||||
variables:
|
||||
api_name: "My API"
|
||||
version: "v1.2.3"
|
||||
```
|
||||
|
||||
### Code Review Template
|
||||
```yaml
|
||||
output:
|
||||
template: "custom"
|
||||
|
||||
custom:
|
||||
header: |
|
||||
# Code Review: {{ .Variables.pr_title }}
|
||||
|
||||
**PR Number:** #{{ .Variables.pr_number }}
|
||||
**Author:** {{ .Variables.author }}
|
||||
**Date:** {{ .Timestamp }}
|
||||
|
||||
## Files Changed ({{ .FileCount }})
|
||||
|
||||
fileHeader: |
|
||||
## 📄 {{ .Path }}
|
||||
|
||||
<details>
|
||||
<summary>{{ .Language | upper }} • {{ .Size | formatSize }}</summary>
|
||||
|
||||
```{{ .Language }}
|
||||
|
||||
fileFooter: |
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
footer: |
|
||||
---
|
||||
|
||||
**Review Summary:**
|
||||
- Files reviewed: {{ .FileCount }}
|
||||
- Total size: {{ .TotalSize | formatSize }}
|
||||
- Generated in: {{ .ProcessingTime }}
|
||||
|
||||
variables:
|
||||
pr_title: "Feature Implementation"
|
||||
pr_number: "123"
|
||||
author: "developer@example.com"
|
||||
```
|
||||
221
fileproc/backpressure.go
Normal file
221
fileproc/backpressure.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Package fileproc provides back-pressure management for memory optimization.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// BackpressureManager manages memory usage and applies back-pressure when needed.
|
||||
type BackpressureManager struct {
|
||||
enabled bool
|
||||
maxMemoryUsage int64
|
||||
memoryCheckInterval int
|
||||
maxPendingFiles int
|
||||
maxPendingWrites int
|
||||
filesProcessed int64
|
||||
mu sync.RWMutex
|
||||
memoryWarningLogged bool
|
||||
lastMemoryCheck time.Time
|
||||
}
|
||||
|
||||
// NewBackpressureManager creates a new back-pressure manager with configuration.
|
||||
func NewBackpressureManager() *BackpressureManager {
|
||||
return &BackpressureManager{
|
||||
enabled: config.BackpressureEnabled(),
|
||||
maxMemoryUsage: config.MaxMemoryUsage(),
|
||||
memoryCheckInterval: config.MemoryCheckInterval(),
|
||||
maxPendingFiles: config.MaxPendingFiles(),
|
||||
maxPendingWrites: config.MaxPendingWrites(),
|
||||
lastMemoryCheck: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateChannels creates properly sized channels based on back-pressure configuration.
|
||||
func (bp *BackpressureManager) CreateChannels() (chan string, chan WriteRequest) {
|
||||
var fileCh chan string
|
||||
var writeCh chan WriteRequest
|
||||
|
||||
logger := shared.GetLogger()
|
||||
if bp.enabled {
|
||||
// Use buffered channels with configured limits
|
||||
fileCh = make(chan string, bp.maxPendingFiles)
|
||||
writeCh = make(chan WriteRequest, bp.maxPendingWrites)
|
||||
logger.Debugf("Created buffered channels: files=%d, writes=%d", bp.maxPendingFiles, bp.maxPendingWrites)
|
||||
} else {
|
||||
// Use unbuffered channels (default behavior)
|
||||
fileCh = make(chan string)
|
||||
writeCh = make(chan WriteRequest)
|
||||
logger.Debug("Created unbuffered channels (back-pressure disabled)")
|
||||
}
|
||||
|
||||
return fileCh, writeCh
|
||||
}
|
||||
|
||||
// ShouldApplyBackpressure checks if back-pressure should be applied.
|
||||
func (bp *BackpressureManager) ShouldApplyBackpressure(ctx context.Context) bool {
|
||||
// Check for context cancellation first
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false // No need for backpressure if canceled
|
||||
default:
|
||||
}
|
||||
|
||||
if !bp.enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if we should evaluate memory usage
|
||||
filesProcessed := atomic.AddInt64(&bp.filesProcessed, 1)
|
||||
|
||||
// Guard against zero or negative interval to avoid modulo-by-zero panic
|
||||
interval := bp.memoryCheckInterval
|
||||
if interval <= 0 {
|
||||
interval = 1
|
||||
}
|
||||
|
||||
if int(filesProcessed)%interval != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get current memory usage
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
currentMemory := shared.SafeUint64ToInt64WithDefault(m.Alloc, 0)
|
||||
|
||||
bp.mu.Lock()
|
||||
defer bp.mu.Unlock()
|
||||
|
||||
bp.lastMemoryCheck = time.Now()
|
||||
|
||||
// Check if we're over the memory limit
|
||||
logger := shared.GetLogger()
|
||||
if currentMemory > bp.maxMemoryUsage {
|
||||
if !bp.memoryWarningLogged {
|
||||
logger.Warnf(
|
||||
"Memory usage (%d bytes) exceeds limit (%d bytes), applying back-pressure",
|
||||
currentMemory, bp.maxMemoryUsage,
|
||||
)
|
||||
bp.memoryWarningLogged = true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Reset warning flag if we're back under the limit
|
||||
if bp.memoryWarningLogged && currentMemory < bp.maxMemoryUsage*8/10 { // 80% of limit
|
||||
logger.Infof("Memory usage normalized (%d bytes), removing back-pressure", currentMemory)
|
||||
bp.memoryWarningLogged = false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ApplyBackpressure applies back-pressure by triggering garbage collection and adding delay.
|
||||
func (bp *BackpressureManager) ApplyBackpressure(ctx context.Context) {
|
||||
if !bp.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Force garbage collection to free up memory
|
||||
runtime.GC()
|
||||
|
||||
// Add a small delay to allow memory to be freed
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
// Small delay to allow GC to complete
|
||||
}
|
||||
|
||||
// Log memory usage after GC
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
logger := shared.GetLogger()
|
||||
logger.Debugf("Applied back-pressure: memory after GC = %d bytes", m.Alloc)
|
||||
}
|
||||
|
||||
// Stats returns current back-pressure statistics.
|
||||
func (bp *BackpressureManager) Stats() BackpressureStats {
|
||||
bp.mu.RLock()
|
||||
defer bp.mu.RUnlock()
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
return BackpressureStats{
|
||||
Enabled: bp.enabled,
|
||||
FilesProcessed: atomic.LoadInt64(&bp.filesProcessed),
|
||||
CurrentMemoryUsage: shared.SafeUint64ToInt64WithDefault(m.Alloc, 0),
|
||||
MaxMemoryUsage: bp.maxMemoryUsage,
|
||||
MemoryWarningActive: bp.memoryWarningLogged,
|
||||
LastMemoryCheck: bp.lastMemoryCheck,
|
||||
MaxPendingFiles: bp.maxPendingFiles,
|
||||
MaxPendingWrites: bp.maxPendingWrites,
|
||||
}
|
||||
}
|
||||
|
||||
// BackpressureStats represents back-pressure manager statistics.
|
||||
type BackpressureStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
FilesProcessed int64 `json:"files_processed"`
|
||||
CurrentMemoryUsage int64 `json:"current_memory_usage"`
|
||||
MaxMemoryUsage int64 `json:"max_memory_usage"`
|
||||
MemoryWarningActive bool `json:"memory_warning_active"`
|
||||
LastMemoryCheck time.Time `json:"last_memory_check"`
|
||||
MaxPendingFiles int `json:"max_pending_files"`
|
||||
MaxPendingWrites int `json:"max_pending_writes"`
|
||||
}
|
||||
|
||||
// WaitForChannelSpace waits for space in channels if they're getting full.
|
||||
func (bp *BackpressureManager) WaitForChannelSpace(ctx context.Context, fileCh chan string, writeCh chan WriteRequest) {
|
||||
if !bp.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
// Check if file channel is getting full (>90% capacity)
|
||||
fileCap := cap(fileCh)
|
||||
if fileCap > 0 && len(fileCh) > fileCap*9/10 {
|
||||
logger.Debugf("File channel is %d%% full, waiting for space", len(fileCh)*100/fileCap)
|
||||
|
||||
// Wait a bit for the channel to drain
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Check if write channel is getting full (>90% capacity)
|
||||
writeCap := cap(writeCh)
|
||||
if writeCap > 0 && len(writeCh) > writeCap*9/10 {
|
||||
logger.Debugf("Write channel is %d%% full, waiting for space", len(writeCh)*100/writeCap)
|
||||
|
||||
// Wait a bit for the channel to drain
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LogBackpressureInfo logs back-pressure configuration and status.
|
||||
func (bp *BackpressureManager) LogBackpressureInfo() {
|
||||
logger := shared.GetLogger()
|
||||
if bp.enabled {
|
||||
logger.Infof(
|
||||
"Back-pressure enabled: maxMemory=%dMB, fileBuffer=%d, writeBuffer=%d, checkInterval=%d",
|
||||
bp.maxMemoryUsage/int64(shared.BytesPerMB), bp.maxPendingFiles, bp.maxPendingWrites, bp.memoryCheckInterval,
|
||||
)
|
||||
} else {
|
||||
logger.Info("Back-pressure disabled")
|
||||
}
|
||||
}
|
||||
344
fileproc/backpressure_test.go
Normal file
344
fileproc/backpressure_test.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package fileproc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestNewBackpressureManager(t *testing.T) {
|
||||
// Test creating a new backpressure manager
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
if bp == nil {
|
||||
t.Error("Expected backpressure manager to be created, got nil")
|
||||
}
|
||||
|
||||
// The backpressure manager should be initialized with config values
|
||||
// We can't test the internal values directly since they're private,
|
||||
// but we can test that it was created successfully
|
||||
}
|
||||
|
||||
func TestBackpressureManagerCreateChannels(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Test creating channels
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Verify channels are created
|
||||
if fileCh == nil {
|
||||
t.Error("Expected file channel to be created, got nil")
|
||||
}
|
||||
if writeCh == nil {
|
||||
t.Error("Expected write channel to be created, got nil")
|
||||
}
|
||||
|
||||
// Test that channels can be used
|
||||
select {
|
||||
case fileCh <- "test-file":
|
||||
// Successfully sent to channel
|
||||
default:
|
||||
t.Error("Unable to send to file channel")
|
||||
}
|
||||
|
||||
// Read from channel
|
||||
select {
|
||||
case file := <-fileCh:
|
||||
if file != "test-file" {
|
||||
t.Errorf("Expected 'test-file', got %s", file)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timeout reading from file channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackpressureManagerShouldApplyBackpressure(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Test backpressure decision
|
||||
shouldApply := bp.ShouldApplyBackpressure(ctx)
|
||||
|
||||
// Since we're using default config, backpressure behavior depends on settings
|
||||
// We just test that the method returns without error
|
||||
// shouldApply is a valid boolean value
|
||||
_ = shouldApply
|
||||
}
|
||||
|
||||
func TestBackpressureManagerApplyBackpressure(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Test applying backpressure
|
||||
bp.ApplyBackpressure(ctx)
|
||||
|
||||
// ApplyBackpressure is a void method that should not panic
|
||||
// If we reach here, the method executed successfully
|
||||
}
|
||||
|
||||
func TestBackpressureManagerApplyBackpressureWithCancellation(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Create canceled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
// Test applying backpressure with canceled context
|
||||
bp.ApplyBackpressure(ctx)
|
||||
|
||||
// ApplyBackpressure doesn't return errors, but should handle cancellation gracefully
|
||||
// If we reach here without hanging, the method handled cancellation properly
|
||||
}
|
||||
|
||||
func TestBackpressureManagerGetStats(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Test getting stats
|
||||
stats := bp.Stats()
|
||||
|
||||
// Stats should contain relevant information
|
||||
if stats.FilesProcessed < 0 {
|
||||
t.Error("Expected non-negative files processed count")
|
||||
}
|
||||
|
||||
if stats.CurrentMemoryUsage < 0 {
|
||||
t.Error("Expected non-negative memory usage")
|
||||
}
|
||||
|
||||
if stats.MaxMemoryUsage < 0 {
|
||||
t.Error("Expected non-negative max memory usage")
|
||||
}
|
||||
|
||||
// Test that stats have reasonable values
|
||||
if stats.MaxPendingFiles < 0 || stats.MaxPendingWrites < 0 {
|
||||
t.Error("Expected non-negative channel buffer sizes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackpressureManagerWaitForChannelSpace(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create test channels
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Test waiting for channel space
|
||||
bp.WaitForChannelSpace(ctx, fileCh, writeCh)
|
||||
|
||||
// WaitForChannelSpace is void method that should complete without hanging
|
||||
// If we reach here, the method executed successfully
|
||||
}
|
||||
|
||||
func TestBackpressureManagerWaitForChannelSpaceWithCancellation(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Create canceled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
// Create test channels
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Test waiting for channel space with canceled context
|
||||
bp.WaitForChannelSpace(ctx, fileCh, writeCh)
|
||||
|
||||
// WaitForChannelSpace should handle cancellation gracefully without hanging
|
||||
// If we reach here, the method handled cancellation properly
|
||||
}
|
||||
|
||||
func TestBackpressureManagerLogBackpressureInfo(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Test logging backpressure info
|
||||
// This method primarily logs information, so we test it executes without panic
|
||||
bp.LogBackpressureInfo()
|
||||
|
||||
// If we reach here without panic, the method worked
|
||||
}
|
||||
|
||||
// BenchmarkBackpressureManager benchmarks backpressure operations.
|
||||
func BenchmarkBackpressureManagerCreateChannels(b *testing.B) {
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Use channels to prevent optimization
|
||||
_ = fileCh
|
||||
_ = writeCh
|
||||
|
||||
runtime.GC() // Force GC to measure memory impact
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBackpressureManagerShouldApplyBackpressure(b *testing.B) {
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
shouldApply := bp.ShouldApplyBackpressure(ctx)
|
||||
_ = shouldApply // Prevent optimization
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBackpressureManagerApplyBackpressure(b *testing.B) {
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bp.ApplyBackpressure(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBackpressureManagerGetStats(b *testing.B) {
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
stats := bp.Stats()
|
||||
_ = stats // Prevent optimization
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackpressureManager_ShouldApplyBackpressure_EdgeCases tests various edge cases for backpressure decision.
|
||||
func TestBackpressureManagerShouldApplyBackpressureEdgeCases(t *testing.T) {
|
||||
testutil.ApplyBackpressureOverrides(t, map[string]any{
|
||||
shared.ConfigKeyBackpressureEnabled: true,
|
||||
"backpressure.memory_check_interval": 2,
|
||||
"backpressure.memory_limit_mb": 1,
|
||||
})
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Test multiple calls to trigger memory check interval logic
|
||||
for i := 0; i < 10; i++ {
|
||||
shouldApply := bp.ShouldApplyBackpressure(ctx)
|
||||
_ = shouldApply
|
||||
}
|
||||
|
||||
// At this point, memory checking should have triggered multiple times
|
||||
// The actual decision depends on memory usage, but we're testing the paths
|
||||
}
|
||||
|
||||
// TestBackpressureManager_CreateChannels_EdgeCases tests edge cases in channel creation.
|
||||
func TestBackpressureManagerCreateChannelsEdgeCases(t *testing.T) {
|
||||
// Test with custom configuration that might trigger different buffer sizes
|
||||
testutil.ApplyBackpressureOverrides(t, map[string]any{
|
||||
"backpressure.file_buffer_size": 50,
|
||||
"backpressure.write_buffer_size": 25,
|
||||
})
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
|
||||
// Create multiple channel sets to test resource management
|
||||
for i := 0; i < 5; i++ {
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Verify channels work correctly
|
||||
select {
|
||||
case fileCh <- "test":
|
||||
// Good - channel accepted value
|
||||
default:
|
||||
// This is also acceptable if buffer is full
|
||||
}
|
||||
|
||||
// Test write channel
|
||||
select {
|
||||
case writeCh <- fileproc.WriteRequest{Path: "test", Content: "content"}:
|
||||
// Good - channel accepted value
|
||||
default:
|
||||
// This is also acceptable if buffer is full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackpressureManager_WaitForChannelSpace_EdgeCases tests edge cases in channel space waiting.
|
||||
func TestBackpressureManagerWaitForChannelSpaceEdgeCases(t *testing.T) {
|
||||
testutil.ApplyBackpressureOverrides(t, map[string]any{
|
||||
shared.ConfigKeyBackpressureEnabled: true,
|
||||
"backpressure.wait_timeout_ms": 10,
|
||||
})
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create channels with small buffers
|
||||
fileCh, writeCh := bp.CreateChannels()
|
||||
|
||||
// Fill up the channels to create pressure
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
select {
|
||||
case fileCh <- "file":
|
||||
case <-time.After(1 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
select {
|
||||
case writeCh <- fileproc.WriteRequest{Path: "test", Content: "content"}:
|
||||
case <-time.After(1 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for channel space - should handle the full channels
|
||||
bp.WaitForChannelSpace(ctx, fileCh, writeCh)
|
||||
}
|
||||
|
||||
// TestBackpressureManager_MemoryPressure tests behavior under simulated memory pressure.
|
||||
func TestBackpressureManagerMemoryPressure(t *testing.T) {
|
||||
// Test with very low memory limit to trigger backpressure
|
||||
testutil.ApplyBackpressureOverrides(t, map[string]any{
|
||||
shared.ConfigKeyBackpressureEnabled: true,
|
||||
"backpressure.memory_limit_mb": 0.001,
|
||||
"backpressure.memory_check_interval": 1,
|
||||
})
|
||||
|
||||
bp := fileproc.NewBackpressureManager()
|
||||
ctx := context.Background()
|
||||
|
||||
// Allocate some memory to potentially trigger limits
|
||||
largeBuffer := make([]byte, 1024*1024) // 1MB
|
||||
_ = largeBuffer[0]
|
||||
|
||||
// Test backpressure decision under memory pressure
|
||||
for i := 0; i < 5; i++ {
|
||||
shouldApply := bp.ShouldApplyBackpressure(ctx)
|
||||
if shouldApply {
|
||||
// Test applying backpressure when needed
|
||||
bp.ApplyBackpressure(ctx)
|
||||
t.Log("Backpressure applied due to memory pressure")
|
||||
}
|
||||
}
|
||||
|
||||
// Test logging
|
||||
bp.LogBackpressureInfo()
|
||||
}
|
||||
130
fileproc/cache.go
Normal file
130
fileproc/cache.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
// getNormalizedExtension efficiently extracts and normalizes the file extension with caching.
|
||||
func (r *FileTypeRegistry) getNormalizedExtension(filename string) string {
|
||||
// Try cache first (read lock)
|
||||
r.cacheMutex.RLock()
|
||||
if ext, exists := r.extCache[filename]; exists {
|
||||
r.cacheMutex.RUnlock()
|
||||
|
||||
return ext
|
||||
}
|
||||
r.cacheMutex.RUnlock()
|
||||
|
||||
// Compute normalized extension
|
||||
ext := normalizeExtension(filename)
|
||||
|
||||
// Cache the result (write lock)
|
||||
r.cacheMutex.Lock()
|
||||
// Check cache size and clean if needed
|
||||
if len(r.extCache) >= r.maxCacheSize*2 {
|
||||
r.clearExtCache()
|
||||
r.stats.CacheEvictions++
|
||||
}
|
||||
r.extCache[filename] = ext
|
||||
r.cacheMutex.Unlock()
|
||||
|
||||
return ext
|
||||
}
|
||||
|
||||
// getFileTypeResult gets cached file type detection result or computes it.
|
||||
func (r *FileTypeRegistry) getFileTypeResult(filename string) FileTypeResult {
|
||||
ext := r.getNormalizedExtension(filename)
|
||||
|
||||
// Update statistics
|
||||
r.updateStats(func() {
|
||||
r.stats.TotalLookups++
|
||||
})
|
||||
|
||||
// Try cache first (read lock)
|
||||
r.cacheMutex.RLock()
|
||||
if result, exists := r.resultCache[ext]; exists {
|
||||
r.cacheMutex.RUnlock()
|
||||
r.updateStats(func() {
|
||||
r.stats.CacheHits++
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
r.cacheMutex.RUnlock()
|
||||
|
||||
// Cache miss
|
||||
r.updateStats(func() {
|
||||
r.stats.CacheMisses++
|
||||
})
|
||||
|
||||
// Compute result
|
||||
result := FileTypeResult{
|
||||
Extension: ext,
|
||||
IsImage: r.imageExts[ext],
|
||||
IsBinary: r.binaryExts[ext],
|
||||
Language: r.languageMap[ext],
|
||||
}
|
||||
|
||||
// Handle special cases for binary detection (like .DS_Store)
|
||||
if !result.IsBinary && isSpecialFile(filename, r.binaryExts) {
|
||||
result.IsBinary = true
|
||||
}
|
||||
|
||||
// Cache the result (write lock)
|
||||
r.cacheMutex.Lock()
|
||||
if len(r.resultCache) >= r.maxCacheSize {
|
||||
r.clearResultCache()
|
||||
r.stats.CacheEvictions++
|
||||
}
|
||||
r.resultCache[ext] = result
|
||||
r.cacheMutex.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// clearExtCache clears half of the extension cache (LRU-like behavior).
|
||||
func (r *FileTypeRegistry) clearExtCache() {
|
||||
r.clearCache(&r.extCache, r.maxCacheSize)
|
||||
}
|
||||
|
||||
// clearResultCache clears half of the result cache.
|
||||
func (r *FileTypeRegistry) clearResultCache() {
|
||||
newCache := make(map[string]FileTypeResult, r.maxCacheSize)
|
||||
count := 0
|
||||
for k, v := range r.resultCache {
|
||||
if count >= r.maxCacheSize/2 {
|
||||
break
|
||||
}
|
||||
newCache[k] = v
|
||||
count++
|
||||
}
|
||||
r.resultCache = newCache
|
||||
}
|
||||
|
||||
// clearCache is a generic cache clearing function.
|
||||
func (r *FileTypeRegistry) clearCache(cache *map[string]string, maxSize int) {
|
||||
newCache := make(map[string]string, maxSize)
|
||||
count := 0
|
||||
for k, v := range *cache {
|
||||
if count >= maxSize/2 {
|
||||
break
|
||||
}
|
||||
newCache[k] = v
|
||||
count++
|
||||
}
|
||||
*cache = newCache
|
||||
}
|
||||
|
||||
// invalidateCache clears both caches when the registry is modified.
|
||||
func (r *FileTypeRegistry) invalidateCache() {
|
||||
r.cacheMutex.Lock()
|
||||
defer r.cacheMutex.Unlock()
|
||||
|
||||
r.extCache = make(map[string]string, r.maxCacheSize)
|
||||
r.resultCache = make(map[string]FileTypeResult, r.maxCacheSize)
|
||||
r.stats.CacheEvictions++
|
||||
}
|
||||
|
||||
// updateStats safely updates statistics.
|
||||
func (r *FileTypeRegistry) updateStats(fn func()) {
|
||||
r.cacheMutex.Lock()
|
||||
fn()
|
||||
r.cacheMutex.Unlock()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package fileproc
|
||||
// CollectFiles scans the given root directory using the default walker (ProdWalker)
|
||||
// and returns a slice of file paths.
|
||||
func CollectFiles(root string) ([]string, error) {
|
||||
var w Walker = ProdWalker{}
|
||||
w := NewProdWalker()
|
||||
|
||||
return w.Walk(root)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package fileproc
|
||||
package fileproc_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
)
|
||||
|
||||
func TestCollectFilesWithFakeWalker(t *testing.T) {
|
||||
@@ -11,7 +14,7 @@ func TestCollectFilesWithFakeWalker(t *testing.T) {
|
||||
"/path/to/file1.txt",
|
||||
"/path/to/file2.go",
|
||||
}
|
||||
fake := FakeWalker{
|
||||
fake := fileproc.FakeWalker{
|
||||
Files: expectedFiles,
|
||||
Err: nil,
|
||||
}
|
||||
@@ -35,7 +38,7 @@ func TestCollectFilesWithFakeWalker(t *testing.T) {
|
||||
|
||||
func TestCollectFilesError(t *testing.T) {
|
||||
// Fake walker returns an error.
|
||||
fake := FakeWalker{
|
||||
fake := fileproc.FakeWalker{
|
||||
Files: nil,
|
||||
Err: os.ErrNotExist,
|
||||
}
|
||||
@@ -45,3 +48,70 @@ func TestCollectFilesError(t *testing.T) {
|
||||
t.Fatal("Expected an error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectFiles tests the actual CollectFiles function with a real directory.
|
||||
func TestCollectFiles(t *testing.T) {
|
||||
// Create a temporary directory with test files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test files with known supported extensions
|
||||
testFiles := map[string]string{
|
||||
"test1.go": "package main\n\nfunc main() {\n\t// Go file\n}",
|
||||
"test2.py": "# Python file\nprint('hello world')",
|
||||
"test3.js": "// JavaScript file\nconsole.log('hello');",
|
||||
}
|
||||
|
||||
for name, content := range testFiles {
|
||||
filePath := filepath.Join(tmpDir, name)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("Failed to create test file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test CollectFiles
|
||||
files, err := fileproc.CollectFiles(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectFiles failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we got the expected number of files
|
||||
if len(files) != len(testFiles) {
|
||||
t.Errorf("Expected %d files, got %d", len(testFiles), len(files))
|
||||
}
|
||||
|
||||
// Verify all expected files are found
|
||||
foundFiles := make(map[string]bool)
|
||||
for _, file := range files {
|
||||
foundFiles[file] = true
|
||||
}
|
||||
|
||||
for expectedFile := range testFiles {
|
||||
expectedPath := filepath.Join(tmpDir, expectedFile)
|
||||
if !foundFiles[expectedPath] {
|
||||
t.Errorf("Expected file %s not found in results", expectedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectFiles_NonExistentDirectory tests CollectFiles with a non-existent directory.
|
||||
func TestCollectFilesNonExistentDirectory(t *testing.T) {
|
||||
_, err := fileproc.CollectFiles("/non/existent/directory")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent directory, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectFiles_EmptyDirectory tests CollectFiles with an empty directory.
|
||||
func TestCollectFilesEmptyDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Don't create any files
|
||||
|
||||
files, err := fileproc.CollectFiles(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectFiles failed on empty directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 0 {
|
||||
t.Errorf("Expected 0 files in empty directory, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
44
fileproc/config.go
Normal file
44
fileproc/config.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import "strings"
|
||||
|
||||
// ApplyCustomExtensions applies custom extensions from configuration.
|
||||
func (r *FileTypeRegistry) ApplyCustomExtensions(
|
||||
customImages, customBinary []string,
|
||||
customLanguages map[string]string,
|
||||
) {
|
||||
// Add custom image extensions
|
||||
r.addExtensions(customImages, r.AddImageExtension)
|
||||
|
||||
// Add custom binary extensions
|
||||
r.addExtensions(customBinary, r.AddBinaryExtension)
|
||||
|
||||
// Add custom language mappings
|
||||
for ext, lang := range customLanguages {
|
||||
if ext != "" && lang != "" {
|
||||
r.AddLanguageMapping(strings.ToLower(ext), lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// addExtensions is a helper to add multiple extensions.
|
||||
func (r *FileTypeRegistry) addExtensions(extensions []string, adder func(string)) {
|
||||
for _, ext := range extensions {
|
||||
if ext != "" {
|
||||
adder(strings.ToLower(ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureFromSettings applies configuration settings to the registry.
|
||||
// This function is called from main.go after config is loaded to avoid circular imports.
|
||||
func ConfigureFromSettings(
|
||||
customImages, customBinary []string,
|
||||
customLanguages map[string]string,
|
||||
disabledImages, disabledBinary, disabledLanguages []string,
|
||||
) {
|
||||
registry := DefaultRegistry()
|
||||
registry.ApplyCustomExtensions(customImages, customBinary, customLanguages)
|
||||
registry.DisableExtensions(disabledImages, disabledBinary, disabledLanguages)
|
||||
}
|
||||
103
fileproc/detection.go
Normal file
103
fileproc/detection.go
Normal file
@@ -0,0 +1,103 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import "strings"
|
||||
|
||||
// Package-level detection functions
|
||||
|
||||
// IsImage checks if the file extension indicates an image file.
|
||||
func IsImage(filename string) bool {
|
||||
return getRegistry().IsImage(filename)
|
||||
}
|
||||
|
||||
// IsBinary checks if the file extension indicates a binary file.
|
||||
func IsBinary(filename string) bool {
|
||||
return getRegistry().IsBinary(filename)
|
||||
}
|
||||
|
||||
// Language returns the language identifier for the given filename based on its extension.
|
||||
func Language(filename string) string {
|
||||
return getRegistry().Language(filename)
|
||||
}
|
||||
|
||||
// Registry methods for detection
|
||||
|
||||
// IsImage checks if the file extension indicates an image file.
|
||||
func (r *FileTypeRegistry) IsImage(filename string) bool {
|
||||
result := r.getFileTypeResult(filename)
|
||||
|
||||
return result.IsImage
|
||||
}
|
||||
|
||||
// IsBinary checks if the file extension indicates a binary file.
|
||||
func (r *FileTypeRegistry) IsBinary(filename string) bool {
|
||||
result := r.getFileTypeResult(filename)
|
||||
|
||||
return result.IsBinary
|
||||
}
|
||||
|
||||
// Language returns the language identifier for the given filename based on its extension.
|
||||
func (r *FileTypeRegistry) Language(filename string) string {
|
||||
if len(filename) < minExtensionLength {
|
||||
return ""
|
||||
}
|
||||
result := r.getFileTypeResult(filename)
|
||||
|
||||
return result.Language
|
||||
}
|
||||
|
||||
// Extension management methods
|
||||
|
||||
// AddImageExtension adds a new image extension to the registry.
|
||||
func (r *FileTypeRegistry) AddImageExtension(ext string) {
|
||||
r.addExtension(ext, r.imageExts)
|
||||
}
|
||||
|
||||
// AddBinaryExtension adds a new binary extension to the registry.
|
||||
func (r *FileTypeRegistry) AddBinaryExtension(ext string) {
|
||||
r.addExtension(ext, r.binaryExts)
|
||||
}
|
||||
|
||||
// AddLanguageMapping adds a new language mapping to the registry.
|
||||
func (r *FileTypeRegistry) AddLanguageMapping(ext, language string) {
|
||||
r.languageMap[strings.ToLower(ext)] = language
|
||||
r.invalidateCache()
|
||||
}
|
||||
|
||||
// addExtension is a helper to add extensions to a map.
|
||||
func (r *FileTypeRegistry) addExtension(ext string, target map[string]bool) {
|
||||
target[strings.ToLower(ext)] = true
|
||||
r.invalidateCache()
|
||||
}
|
||||
|
||||
// removeExtension is a helper to remove extensions from a map.
|
||||
func (r *FileTypeRegistry) removeExtension(ext string, target map[string]bool) {
|
||||
delete(target, strings.ToLower(ext))
|
||||
}
|
||||
|
||||
// DisableExtensions removes specified extensions from the registry.
|
||||
func (r *FileTypeRegistry) DisableExtensions(disabledImages, disabledBinary, disabledLanguages []string) {
|
||||
// Disable image extensions
|
||||
for _, ext := range disabledImages {
|
||||
if ext != "" {
|
||||
r.removeExtension(ext, r.imageExts)
|
||||
}
|
||||
}
|
||||
|
||||
// Disable binary extensions
|
||||
for _, ext := range disabledBinary {
|
||||
if ext != "" {
|
||||
r.removeExtension(ext, r.binaryExts)
|
||||
}
|
||||
}
|
||||
|
||||
// Disable language extensions
|
||||
for _, ext := range disabledLanguages {
|
||||
if ext != "" {
|
||||
delete(r.languageMap, strings.ToLower(ext))
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate cache after all modifications
|
||||
r.invalidateCache()
|
||||
}
|
||||
164
fileproc/extensions.go
Normal file
164
fileproc/extensions.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import "github.com/ivuorinen/gibidify/shared"
|
||||
|
||||
// getImageExtensions returns the default image file extensions.
|
||||
func getImageExtensions() map[string]bool {
|
||||
return map[string]bool{
|
||||
".png": true,
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".gif": true,
|
||||
".bmp": true,
|
||||
".tiff": true,
|
||||
".tif": true,
|
||||
".svg": true,
|
||||
".webp": true,
|
||||
".ico": true,
|
||||
}
|
||||
}
|
||||
|
||||
// getBinaryExtensions returns the default binary file extensions.
|
||||
func getBinaryExtensions() map[string]bool {
|
||||
return map[string]bool{
|
||||
// Executables and libraries
|
||||
".exe": true,
|
||||
".dll": true,
|
||||
".so": true,
|
||||
".dylib": true,
|
||||
".bin": true,
|
||||
".o": true,
|
||||
".a": true,
|
||||
".lib": true,
|
||||
|
||||
// Compiled bytecode
|
||||
".jar": true,
|
||||
".class": true,
|
||||
".pyc": true,
|
||||
".pyo": true,
|
||||
|
||||
// Data files
|
||||
".dat": true,
|
||||
".db": true,
|
||||
".sqlite": true,
|
||||
".ds_store": true,
|
||||
|
||||
// Documents
|
||||
".pdf": true,
|
||||
|
||||
// Archives
|
||||
".zip": true,
|
||||
".tar": true,
|
||||
".gz": true,
|
||||
".bz2": true,
|
||||
".xz": true,
|
||||
".7z": true,
|
||||
".rar": true,
|
||||
|
||||
// Fonts
|
||||
".ttf": true,
|
||||
".otf": true,
|
||||
".woff": true,
|
||||
".woff2": true,
|
||||
|
||||
// Media files
|
||||
".mp3": true,
|
||||
".mp4": true,
|
||||
".avi": true,
|
||||
".mov": true,
|
||||
".wmv": true,
|
||||
".flv": true,
|
||||
".webm": true,
|
||||
".ogg": true,
|
||||
".wav": true,
|
||||
".flac": true,
|
||||
}
|
||||
}
|
||||
|
||||
// getLanguageMap returns the default language mappings.
|
||||
func getLanguageMap() map[string]string {
|
||||
return map[string]string{
|
||||
// Systems programming
|
||||
".go": "go",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rs": "rust",
|
||||
|
||||
// Scripting languages
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".pl": "perl",
|
||||
".lua": "lua",
|
||||
".php": "php",
|
||||
|
||||
// Web technologies
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "javascript",
|
||||
".tsx": "typescript",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".sass": "sass",
|
||||
".less": "less",
|
||||
".vue": "vue",
|
||||
|
||||
// JVM languages
|
||||
".java": "java",
|
||||
".scala": "scala",
|
||||
".kt": "kotlin",
|
||||
".clj": "clojure",
|
||||
|
||||
// .NET languages
|
||||
".cs": "csharp",
|
||||
".vb": "vbnet",
|
||||
".fs": "fsharp",
|
||||
|
||||
// Apple platforms
|
||||
".swift": "swift",
|
||||
".m": "objc",
|
||||
".mm": "objcpp",
|
||||
|
||||
// Shell scripts
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "zsh",
|
||||
".fish": "fish",
|
||||
".ps1": "powershell",
|
||||
".bat": "batch",
|
||||
".cmd": "batch",
|
||||
|
||||
// Data formats
|
||||
".json": shared.FormatJSON,
|
||||
".yaml": shared.FormatYAML,
|
||||
".yml": shared.FormatYAML,
|
||||
".toml": "toml",
|
||||
".xml": "xml",
|
||||
".sql": "sql",
|
||||
|
||||
// Documentation
|
||||
".md": shared.FormatMarkdown,
|
||||
".rst": "rst",
|
||||
".tex": "latex",
|
||||
|
||||
// Functional languages
|
||||
".hs": "haskell",
|
||||
".ml": "ocaml",
|
||||
".mli": "ocaml",
|
||||
".elm": "elm",
|
||||
".ex": "elixir",
|
||||
".exs": "elixir",
|
||||
".erl": "erlang",
|
||||
".hrl": "erlang",
|
||||
|
||||
// Other languages
|
||||
".r": "r",
|
||||
".dart": "dart",
|
||||
".nim": "nim",
|
||||
".nims": "nim",
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@ package fileproc
|
||||
|
||||
// FakeWalker implements Walker for testing purposes.
|
||||
type FakeWalker struct {
|
||||
Files []string
|
||||
Err error
|
||||
Files []string
|
||||
}
|
||||
|
||||
// Walk returns predetermined file paths or an error, depending on FakeWalker's configuration.
|
||||
func (fw FakeWalker) Walk(root string) ([]string, error) {
|
||||
func (fw FakeWalker) Walk(_ string) ([]string, error) {
|
||||
if fw.Err != nil {
|
||||
return nil, fw.Err
|
||||
}
|
||||
|
||||
return fw.Files, nil
|
||||
}
|
||||
|
||||
57
fileproc/file_filters.go
Normal file
57
fileproc/file_filters.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
)
|
||||
|
||||
// FileFilter defines filtering criteria for files and directories.
|
||||
type FileFilter struct {
|
||||
ignoredDirs []string
|
||||
sizeLimit int64
|
||||
}
|
||||
|
||||
// NewFileFilter creates a new file filter with current configuration.
|
||||
func NewFileFilter() *FileFilter {
|
||||
return &FileFilter{
|
||||
ignoredDirs: config.IgnoredDirectories(),
|
||||
sizeLimit: config.FileSizeLimit(),
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkipEntry determines if an entry should be skipped based on ignore rules and filters.
|
||||
func (f *FileFilter) shouldSkipEntry(entry os.DirEntry, fullPath string, rules []ignoreRule) bool {
|
||||
if entry.IsDir() {
|
||||
return f.shouldSkipDirectory(entry)
|
||||
}
|
||||
|
||||
if f.shouldSkipFile(entry, fullPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
return matchesIgnoreRules(fullPath, rules)
|
||||
}
|
||||
|
||||
// shouldSkipDirectory checks if a directory should be skipped based on the ignored directories list.
|
||||
func (f *FileFilter) shouldSkipDirectory(entry os.DirEntry) bool {
|
||||
for _, d := range f.ignoredDirs {
|
||||
if entry.Name() == d {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldSkipFile checks if a file should be skipped based on size limit and file type.
|
||||
func (f *FileFilter) shouldSkipFile(entry os.DirEntry, fullPath string) bool {
|
||||
// Check if file exceeds the configured size limit.
|
||||
if info, err := entry.Info(); err == nil && info.Size() > f.sizeLimit {
|
||||
return true
|
||||
}
|
||||
|
||||
// Apply the default filter to ignore binary and image files.
|
||||
return IsBinary(fullPath) || IsImage(fullPath)
|
||||
}
|
||||
200
fileproc/filetypes_concurrency_test.go
Normal file
200
fileproc/filetypes_concurrency_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
numGoroutines = 100
|
||||
numOperationsPerGoroutine = 100
|
||||
)
|
||||
|
||||
// TestFileTypeRegistryConcurrentReads tests concurrent read operations.
|
||||
// This test verifies thread-safety of registry reads under concurrent access.
|
||||
// For race condition detection, run with: go test -race
|
||||
func TestFileTypeRegistryConcurrentReads(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Go(func() {
|
||||
if err := performConcurrentReads(); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// Check for any errors from goroutines
|
||||
for err := range errChan {
|
||||
t.Errorf("Concurrent read operation failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryConcurrentRegistryAccess tests concurrent registry access.
|
||||
func TestFileTypeRegistryConcurrentRegistryAccess(t *testing.T) {
|
||||
// Reset the registry to test concurrent initialization
|
||||
ResetRegistryForTesting()
|
||||
t.Cleanup(func() {
|
||||
ResetRegistryForTesting()
|
||||
})
|
||||
|
||||
registries := make([]*FileTypeRegistry, numGoroutines)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
idx := i // capture for closure
|
||||
wg.Go(func() {
|
||||
registries[idx] = DefaultRegistry()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
verifySameRegistryInstance(t, registries)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryConcurrentModifications tests concurrent modifications.
|
||||
func TestFileTypeRegistryConcurrentModifications(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
id := i // capture for closure
|
||||
wg.Go(func() {
|
||||
performConcurrentModifications(t, id)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// performConcurrentReads performs concurrent read operations on the registry.
|
||||
// Returns an error if any operation produces unexpected results.
|
||||
func performConcurrentReads() error {
|
||||
registry := DefaultRegistry()
|
||||
|
||||
for j := 0; j < numOperationsPerGoroutine; j++ {
|
||||
// Test various file detection operations with expected results
|
||||
if !registry.IsImage(shared.TestFilePNG) {
|
||||
return errors.New("expected .png to be detected as image")
|
||||
}
|
||||
if !registry.IsBinary(shared.TestFileEXE) {
|
||||
return errors.New("expected .exe to be detected as binary")
|
||||
}
|
||||
if lang := registry.Language(shared.TestFileGo); lang != "go" {
|
||||
return fmt.Errorf("expected .go to have language 'go', got %q", lang)
|
||||
}
|
||||
|
||||
// Test global functions with expected results
|
||||
if !IsImage(shared.TestFileImageJPG) {
|
||||
return errors.New("expected .jpg to be detected as image")
|
||||
}
|
||||
if !IsBinary(shared.TestFileBinaryDLL) {
|
||||
return errors.New("expected .dll to be detected as binary")
|
||||
}
|
||||
if lang := Language(shared.TestFileScriptPy); lang != "python" {
|
||||
return fmt.Errorf("expected .py to have language 'python', got %q", lang)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifySameRegistryInstance verifies all goroutines got the same registry instance.
|
||||
func verifySameRegistryInstance(t *testing.T, registries []*FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
firstRegistry := registries[0]
|
||||
for i := 1; i < numGoroutines; i++ {
|
||||
if registries[i] != firstRegistry {
|
||||
t.Errorf("Registry %d is different from registry 0", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performConcurrentModifications performs concurrent modifications on separate registry instances.
|
||||
func performConcurrentModifications(t *testing.T, id int) {
|
||||
t.Helper()
|
||||
|
||||
// Create a new registry instance for this goroutine
|
||||
registry := createConcurrencyTestRegistry()
|
||||
|
||||
for j := 0; j < numOperationsPerGoroutine; j++ {
|
||||
extSuffix := fmt.Sprintf("_%d_%d", id, j)
|
||||
|
||||
addTestExtensions(registry, extSuffix)
|
||||
verifyTestExtensions(t, registry, extSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// createConcurrencyTestRegistry creates a new registry instance for concurrency testing.
|
||||
func createConcurrencyTestRegistry() *FileTypeRegistry {
|
||||
return &FileTypeRegistry{
|
||||
imageExts: make(map[string]bool),
|
||||
binaryExts: make(map[string]bool),
|
||||
languageMap: make(map[string]string),
|
||||
extCache: make(map[string]string, shared.FileTypeRegistryMaxCacheSize),
|
||||
resultCache: make(map[string]FileTypeResult, shared.FileTypeRegistryMaxCacheSize),
|
||||
maxCacheSize: shared.FileTypeRegistryMaxCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// addTestExtensions adds test extensions to the registry.
|
||||
func addTestExtensions(registry *FileTypeRegistry, extSuffix string) {
|
||||
registry.AddImageExtension(".img" + extSuffix)
|
||||
registry.AddBinaryExtension(".bin" + extSuffix)
|
||||
registry.AddLanguageMapping(".lang"+extSuffix, "lang"+extSuffix)
|
||||
}
|
||||
|
||||
// verifyTestExtensions verifies that test extensions were added correctly.
|
||||
func verifyTestExtensions(t *testing.T, registry *FileTypeRegistry, extSuffix string) {
|
||||
t.Helper()
|
||||
|
||||
if !registry.IsImage("test.img" + extSuffix) {
|
||||
t.Errorf("Failed to add image extension .img%s", extSuffix)
|
||||
}
|
||||
if !registry.IsBinary("test.bin" + extSuffix) {
|
||||
t.Errorf("Failed to add binary extension .bin%s", extSuffix)
|
||||
}
|
||||
if registry.Language("test.lang"+extSuffix) != "lang"+extSuffix {
|
||||
t.Errorf("Failed to add language mapping .lang%s", extSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks for concurrency performance
|
||||
|
||||
// BenchmarkConcurrentReads benchmarks concurrent read operations on the registry.
|
||||
func BenchmarkConcurrentReads(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = performConcurrentReads()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentRegistryAccess benchmarks concurrent registry singleton access.
|
||||
func BenchmarkConcurrentRegistryAccess(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = DefaultRegistry()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentModifications benchmarks sequential registry modifications.
|
||||
// Note: Concurrent modifications to the same registry require external synchronization.
|
||||
// This benchmark measures the cost of modification operations themselves.
|
||||
func BenchmarkConcurrentModifications(b *testing.B) {
|
||||
for b.Loop() {
|
||||
registry := createConcurrencyTestRegistry()
|
||||
for i := 0; i < 10; i++ {
|
||||
extSuffix := fmt.Sprintf("_bench_%d", i)
|
||||
registry.AddImageExtension(".img" + extSuffix)
|
||||
registry.AddBinaryExtension(".bin" + extSuffix)
|
||||
registry.AddLanguageMapping(".lang"+extSuffix, "lang"+extSuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
310
fileproc/filetypes_config_test.go
Normal file
310
fileproc/filetypes_config_test.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
zigLang = "zig"
|
||||
)
|
||||
|
||||
// TestFileTypeRegistryApplyCustomExtensions tests applying custom extensions.
|
||||
func TestFileTypeRegistryApplyCustomExtensions(t *testing.T) {
|
||||
registry := createEmptyTestRegistry()
|
||||
|
||||
customImages := []string{".webp", ".avif", ".heic"}
|
||||
customBinary := []string{".custom", ".mybin"}
|
||||
customLanguages := map[string]string{
|
||||
".zig": zigLang,
|
||||
".odin": "odin",
|
||||
".v": "vlang",
|
||||
}
|
||||
|
||||
registry.ApplyCustomExtensions(customImages, customBinary, customLanguages)
|
||||
|
||||
verifyCustomExtensions(t, registry, customImages, customBinary, customLanguages)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryDisableExtensions tests disabling extensions.
|
||||
func TestFileTypeRegistryDisableExtensions(t *testing.T) {
|
||||
registry := createEmptyTestRegistry()
|
||||
|
||||
// Add some extensions first
|
||||
setupRegistryExtensions(registry)
|
||||
|
||||
// Verify they work before disabling
|
||||
verifyExtensionsEnabled(t, registry)
|
||||
|
||||
// Disable some extensions
|
||||
disabledImages := []string{".png"}
|
||||
disabledBinary := []string{".exe"}
|
||||
disabledLanguages := []string{".go"}
|
||||
|
||||
registry.DisableExtensions(disabledImages, disabledBinary, disabledLanguages)
|
||||
|
||||
// Verify disabled and remaining extensions
|
||||
verifyExtensionsDisabled(t, registry)
|
||||
verifyRemainingExtensions(t, registry)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryEmptyValuesHandling tests handling of empty values.
|
||||
func TestFileTypeRegistryEmptyValuesHandling(t *testing.T) {
|
||||
registry := createEmptyTestRegistry()
|
||||
|
||||
customImages := []string{"", shared.TestExtensionValid, ""}
|
||||
customBinary := []string{"", shared.TestExtensionValid}
|
||||
customLanguages := map[string]string{
|
||||
"": "invalid",
|
||||
shared.TestExtensionValid: "",
|
||||
".good": "good",
|
||||
}
|
||||
|
||||
registry.ApplyCustomExtensions(customImages, customBinary, customLanguages)
|
||||
|
||||
verifyEmptyValueHandling(t, registry)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryCaseInsensitiveHandling tests case insensitive handling.
|
||||
func TestFileTypeRegistryCaseInsensitiveHandling(t *testing.T) {
|
||||
registry := createEmptyTestRegistry()
|
||||
|
||||
customImages := []string{".WEBP", ".Avif"}
|
||||
customBinary := []string{".CUSTOM", ".MyBin"}
|
||||
customLanguages := map[string]string{
|
||||
".ZIG": zigLang,
|
||||
".Odin": "odin",
|
||||
}
|
||||
|
||||
registry.ApplyCustomExtensions(customImages, customBinary, customLanguages)
|
||||
|
||||
verifyCaseInsensitiveHandling(t, registry)
|
||||
}
|
||||
|
||||
// createEmptyTestRegistry creates a new empty test registry instance for config testing.
|
||||
func createEmptyTestRegistry() *FileTypeRegistry {
|
||||
return &FileTypeRegistry{
|
||||
imageExts: make(map[string]bool),
|
||||
binaryExts: make(map[string]bool),
|
||||
languageMap: make(map[string]string),
|
||||
extCache: make(map[string]string, shared.FileTypeRegistryMaxCacheSize),
|
||||
resultCache: make(map[string]FileTypeResult, shared.FileTypeRegistryMaxCacheSize),
|
||||
maxCacheSize: shared.FileTypeRegistryMaxCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// verifyCustomExtensions verifies that custom extensions are applied correctly.
|
||||
func verifyCustomExtensions(
|
||||
t *testing.T,
|
||||
registry *FileTypeRegistry,
|
||||
customImages, customBinary []string,
|
||||
customLanguages map[string]string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Test custom image extensions
|
||||
for _, ext := range customImages {
|
||||
if !registry.IsImage("test" + ext) {
|
||||
t.Errorf("Expected %s to be recognized as image", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// Test custom binary extensions
|
||||
for _, ext := range customBinary {
|
||||
if !registry.IsBinary("test" + ext) {
|
||||
t.Errorf("Expected %s to be recognized as binary", ext)
|
||||
}
|
||||
}
|
||||
|
||||
// Test custom language mappings
|
||||
for ext, expectedLang := range customLanguages {
|
||||
if lang := registry.Language("test" + ext); lang != expectedLang {
|
||||
t.Errorf("Expected %s to map to %s, got %s", ext, expectedLang, lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupRegistryExtensions adds test extensions to the registry.
|
||||
func setupRegistryExtensions(registry *FileTypeRegistry) {
|
||||
registry.AddImageExtension(".png")
|
||||
registry.AddImageExtension(".jpg")
|
||||
registry.AddBinaryExtension(".exe")
|
||||
registry.AddBinaryExtension(".dll")
|
||||
registry.AddLanguageMapping(".go", "go")
|
||||
registry.AddLanguageMapping(".py", "python")
|
||||
}
|
||||
|
||||
// verifyExtensionsEnabled verifies that extensions are enabled before disabling.
|
||||
func verifyExtensionsEnabled(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
if !registry.IsImage(shared.TestFilePNG) {
|
||||
t.Error("Expected .png to be image before disabling")
|
||||
}
|
||||
if !registry.IsBinary(shared.TestFileEXE) {
|
||||
t.Error("Expected .exe to be binary before disabling")
|
||||
}
|
||||
if registry.Language(shared.TestFileGo) != "go" {
|
||||
t.Error("Expected .go to map to go before disabling")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyExtensionsDisabled verifies that disabled extensions no longer work.
|
||||
func verifyExtensionsDisabled(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
if registry.IsImage(shared.TestFilePNG) {
|
||||
t.Error("Expected .png to not be image after disabling")
|
||||
}
|
||||
if registry.IsBinary(shared.TestFileEXE) {
|
||||
t.Error("Expected .exe to not be binary after disabling")
|
||||
}
|
||||
if registry.Language(shared.TestFileGo) != "" {
|
||||
t.Error("Expected .go to not map to language after disabling")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyRemainingExtensions verifies that non-disabled extensions still work.
|
||||
func verifyRemainingExtensions(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
if !registry.IsImage(shared.TestFileJPG) {
|
||||
t.Error("Expected .jpg to still be image after disabling .png")
|
||||
}
|
||||
if !registry.IsBinary(shared.TestFileDLL) {
|
||||
t.Error("Expected .dll to still be binary after disabling .exe")
|
||||
}
|
||||
if registry.Language(shared.TestFilePy) != "python" {
|
||||
t.Error("Expected .py to still map to python after disabling .go")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyEmptyValueHandling verifies handling of empty values.
|
||||
func verifyEmptyValueHandling(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
if registry.IsImage("test") {
|
||||
t.Error("Expected empty extension to not be added as image")
|
||||
}
|
||||
if !registry.IsImage(shared.TestFileValid) {
|
||||
t.Error("Expected .valid to be added as image")
|
||||
}
|
||||
if registry.IsBinary("test") {
|
||||
t.Error("Expected empty extension to not be added as binary")
|
||||
}
|
||||
if !registry.IsBinary(shared.TestFileValid) {
|
||||
t.Error("Expected .valid to be added as binary")
|
||||
}
|
||||
if registry.Language("test") != "" {
|
||||
t.Error("Expected empty extension to not be added as language")
|
||||
}
|
||||
if registry.Language(shared.TestFileValid) != "" {
|
||||
t.Error("Expected .valid with empty language to not be added")
|
||||
}
|
||||
if registry.Language("test.good") != "good" {
|
||||
t.Error("Expected .good to map to good")
|
||||
}
|
||||
}
|
||||
|
||||
// verifyCaseInsensitiveHandling verifies case insensitive handling.
|
||||
func verifyCaseInsensitiveHandling(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
if !registry.IsImage(shared.TestFileWebP) {
|
||||
t.Error("Expected .webp (lowercase) to work after adding .WEBP")
|
||||
}
|
||||
if !registry.IsImage("test.WEBP") {
|
||||
t.Error("Expected .WEBP (uppercase) to work")
|
||||
}
|
||||
if !registry.IsBinary("test.custom") {
|
||||
t.Error("Expected .custom (lowercase) to work after adding .CUSTOM")
|
||||
}
|
||||
if !registry.IsBinary("test.CUSTOM") {
|
||||
t.Error("Expected .CUSTOM (uppercase) to work")
|
||||
}
|
||||
if registry.Language("test.zig") != zigLang {
|
||||
t.Error("Expected .zig (lowercase) to work after adding .ZIG")
|
||||
}
|
||||
if registry.Language("test.ZIG") != zigLang {
|
||||
t.Error("Expected .ZIG (uppercase) to work")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigureFromSettings tests the global configuration function.
|
||||
func TestConfigureFromSettings(t *testing.T) {
|
||||
// Reset registry to ensure clean state
|
||||
ResetRegistryForTesting()
|
||||
|
||||
// Test configuration application
|
||||
customImages := []string{".webp", ".avif"}
|
||||
customBinary := []string{".custom"}
|
||||
customLanguages := map[string]string{".zig": zigLang}
|
||||
disabledImages := []string{".gif"} // Disable default extension
|
||||
disabledBinary := []string{".exe"} // Disable default extension
|
||||
disabledLanguages := []string{".rb"} // Disable default extension
|
||||
|
||||
ConfigureFromSettings(
|
||||
customImages,
|
||||
customBinary,
|
||||
customLanguages,
|
||||
disabledImages,
|
||||
disabledBinary,
|
||||
disabledLanguages,
|
||||
)
|
||||
|
||||
// Test that custom extensions work
|
||||
if !IsImage(shared.TestFileWebP) {
|
||||
t.Error("Expected custom image extension .webp to work")
|
||||
}
|
||||
if !IsBinary("test.custom") {
|
||||
t.Error("Expected custom binary extension .custom to work")
|
||||
}
|
||||
if Language("test.zig") != zigLang {
|
||||
t.Error("Expected custom language .zig to work")
|
||||
}
|
||||
|
||||
// Test that disabled extensions don't work
|
||||
if IsImage("test.gif") {
|
||||
t.Error("Expected disabled image extension .gif to not work")
|
||||
}
|
||||
if IsBinary(shared.TestFileEXE) {
|
||||
t.Error("Expected disabled binary extension .exe to not work")
|
||||
}
|
||||
if Language("test.rb") != "" {
|
||||
t.Error("Expected disabled language extension .rb to not work")
|
||||
}
|
||||
|
||||
// Test that non-disabled defaults still work
|
||||
if !IsImage(shared.TestFilePNG) {
|
||||
t.Error("Expected non-disabled image extension .png to still work")
|
||||
}
|
||||
if !IsBinary(shared.TestFileDLL) {
|
||||
t.Error("Expected non-disabled binary extension .dll to still work")
|
||||
}
|
||||
if Language(shared.TestFileGo) != "go" {
|
||||
t.Error("Expected non-disabled language extension .go to still work")
|
||||
}
|
||||
|
||||
// Test multiple calls don't override previous configuration
|
||||
ConfigureFromSettings(
|
||||
[]string{".extra"},
|
||||
[]string{},
|
||||
map[string]string{},
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{},
|
||||
)
|
||||
|
||||
// Previous configuration should still work
|
||||
if !IsImage(shared.TestFileWebP) {
|
||||
t.Error("Expected previous configuration to persist")
|
||||
}
|
||||
// New configuration should also work
|
||||
if !IsImage("test.extra") {
|
||||
t.Error("Expected new configuration to be applied")
|
||||
}
|
||||
|
||||
// Reset registry after test to avoid affecting other tests
|
||||
ResetRegistryForTesting()
|
||||
}
|
||||
241
fileproc/filetypes_detection_test.go
Normal file
241
fileproc/filetypes_detection_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// createTestRegistry creates a fresh FileTypeRegistry instance for testing.
|
||||
// This helper reduces code duplication and ensures consistent registry initialization.
|
||||
func createTestRegistry() *FileTypeRegistry {
|
||||
return &FileTypeRegistry{
|
||||
imageExts: getImageExtensions(),
|
||||
binaryExts: getBinaryExtensions(),
|
||||
languageMap: getLanguageMap(),
|
||||
extCache: make(map[string]string, shared.FileTypeRegistryMaxCacheSize),
|
||||
resultCache: make(map[string]FileTypeResult, shared.FileTypeRegistryMaxCacheSize),
|
||||
maxCacheSize: shared.FileTypeRegistryMaxCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistry_LanguageDetection tests the language detection functionality.
|
||||
func TestFileTypeRegistryLanguageDetection(t *testing.T) {
|
||||
registry := createTestRegistry()
|
||||
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
// Programming languages
|
||||
{shared.TestFileMainGo, "go"},
|
||||
{shared.TestFileScriptPy, "python"},
|
||||
{"app.js", "javascript"},
|
||||
{"component.tsx", "typescript"},
|
||||
{"service.ts", "typescript"},
|
||||
{"App.java", "java"},
|
||||
{"program.c", "c"},
|
||||
{"program.cpp", "cpp"},
|
||||
{"header.h", "c"},
|
||||
{"header.hpp", "cpp"},
|
||||
{"main.rs", "rust"},
|
||||
{"script.rb", "ruby"},
|
||||
{"index.php", "php"},
|
||||
{"app.swift", "swift"},
|
||||
{"MainActivity.kt", "kotlin"},
|
||||
{"Main.scala", "scala"},
|
||||
{"analysis.r", "r"},
|
||||
{"ViewController.m", "objc"},
|
||||
{"ViewController.mm", "objcpp"},
|
||||
{"Program.cs", "csharp"},
|
||||
{"Module.vb", "vbnet"},
|
||||
{"program.fs", "fsharp"},
|
||||
{"script.lua", "lua"},
|
||||
{"script.pl", "perl"},
|
||||
|
||||
// Shell scripts
|
||||
{"script.sh", "bash"},
|
||||
{"script.bash", "bash"},
|
||||
{"script.zsh", "zsh"},
|
||||
{"script.fish", "fish"},
|
||||
{"script.ps1", "powershell"},
|
||||
{"script.bat", "batch"},
|
||||
{"script.cmd", "batch"},
|
||||
|
||||
// Data and markup
|
||||
{"query.sql", "sql"},
|
||||
{"index.html", "html"},
|
||||
{"page.htm", "html"},
|
||||
{"data.xml", "xml"},
|
||||
{"style.css", "css"},
|
||||
{"style.scss", "scss"},
|
||||
{"style.sass", "sass"},
|
||||
{"style.less", "less"},
|
||||
{"config.json", "json"},
|
||||
{"config.yaml", "yaml"},
|
||||
{"config.yml", "yaml"},
|
||||
{"data.toml", "toml"},
|
||||
{"page.md", "markdown"},
|
||||
{"readme.markdown", ""},
|
||||
{"doc.rst", "rst"},
|
||||
{"book.tex", "latex"},
|
||||
|
||||
// Configuration files
|
||||
{"Dockerfile", ""},
|
||||
{"Makefile", ""},
|
||||
{"GNUmakefile", ""},
|
||||
|
||||
// Case sensitivity tests
|
||||
{"MAIN.GO", "go"},
|
||||
{"SCRIPT.PY", "python"},
|
||||
{"APP.JS", "javascript"},
|
||||
|
||||
// Unknown extensions
|
||||
{"unknown.xyz", ""},
|
||||
{"file.unknown", ""},
|
||||
{"noextension", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
result := registry.Language(tt.filename)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Language(%q) = %q, expected %q", tt.filename, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistry_ImageDetection tests the image detection functionality.
|
||||
func TestFileTypeRegistryImageDetection(t *testing.T) {
|
||||
registry := createTestRegistry()
|
||||
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected bool
|
||||
}{
|
||||
// Common image formats
|
||||
{"photo.png", true},
|
||||
{shared.TestFileImageJPG, true},
|
||||
{"picture.jpeg", true},
|
||||
{"animation.gif", true},
|
||||
{"bitmap.bmp", true},
|
||||
{"image.tiff", true},
|
||||
{"scan.tif", true},
|
||||
{"vector.svg", true},
|
||||
{"modern.webp", true},
|
||||
{"favicon.ico", true},
|
||||
|
||||
// Case sensitivity tests
|
||||
{"PHOTO.PNG", true},
|
||||
{"IMAGE.JPG", true},
|
||||
{"PICTURE.JPEG", true},
|
||||
|
||||
// Non-image files
|
||||
{"document.txt", false},
|
||||
{"script.js", false},
|
||||
{"data.json", false},
|
||||
{"archive.zip", false},
|
||||
{"executable.exe", false},
|
||||
|
||||
// Edge cases
|
||||
{"", false}, // Empty filename
|
||||
{"image", false}, // No extension
|
||||
{".png", true}, // Just extension
|
||||
{"file.png.bak", false}, // Multiple extensions
|
||||
{"image.unknown", false}, // Unknown extension
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
result := registry.IsImage(tt.filename)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsImage(%q) = %t, expected %t", tt.filename, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistry_BinaryDetection tests the binary detection functionality.
|
||||
func TestFileTypeRegistryBinaryDetection(t *testing.T) {
|
||||
registry := createTestRegistry()
|
||||
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected bool
|
||||
}{
|
||||
// Executable files
|
||||
{"program.exe", true},
|
||||
{"library.dll", true},
|
||||
{"libfoo.so", true},
|
||||
{"framework.dylib", true},
|
||||
{"data.bin", true},
|
||||
|
||||
// Object and library files
|
||||
{"object.o", true},
|
||||
{"archive.a", true},
|
||||
{"library.lib", true},
|
||||
{"application.jar", true},
|
||||
{"bytecode.class", true},
|
||||
{"compiled.pyc", true},
|
||||
{"optimized.pyo", true},
|
||||
|
||||
// System files
|
||||
{".DS_Store", true},
|
||||
|
||||
// Document files (treated as binary)
|
||||
{"document.pdf", true},
|
||||
|
||||
// Archive files
|
||||
{"archive.zip", true},
|
||||
{"backup.tar", true},
|
||||
{"compressed.gz", true},
|
||||
{"data.bz2", true},
|
||||
{"package.xz", true},
|
||||
{"archive.7z", true},
|
||||
{"backup.rar", true},
|
||||
|
||||
// Font files
|
||||
{"font.ttf", true},
|
||||
{"font.otf", true},
|
||||
{"font.woff", true},
|
||||
{"font.woff2", true},
|
||||
|
||||
// Media files (video/audio)
|
||||
{"video.mp4", true},
|
||||
{"movie.avi", true},
|
||||
{"clip.mov", true},
|
||||
{"song.mp3", true},
|
||||
{"audio.wav", true},
|
||||
{"music.flac", true},
|
||||
|
||||
// Case sensitivity tests
|
||||
{"PROGRAM.EXE", true},
|
||||
{"LIBRARY.DLL", true},
|
||||
{"ARCHIVE.ZIP", true},
|
||||
|
||||
// Non-binary files
|
||||
{"document.txt", false},
|
||||
{shared.TestFileScriptPy, false},
|
||||
{"config.json", false},
|
||||
{"style.css", false},
|
||||
{"page.html", false},
|
||||
|
||||
// Edge cases
|
||||
{"", false}, // Empty filename
|
||||
{"binary", false}, // No extension
|
||||
{".exe", true}, // Just extension
|
||||
{"file.exe.txt", false}, // Multiple extensions
|
||||
{"file.unknown", false}, // Unknown extension
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
result := registry.IsBinary(tt.filename)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsBinary(%q) = %t, expected %t", tt.filename, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
130
fileproc/filetypes_edge_cases_test.go
Normal file
130
fileproc/filetypes_edge_cases_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// TestFileTypeRegistry_EdgeCases tests edge cases and boundary conditions.
|
||||
func TestFileTypeRegistryEdgeCases(t *testing.T) {
|
||||
registry := DefaultRegistry()
|
||||
|
||||
// Test various edge cases for filename handling
|
||||
edgeCases := []struct {
|
||||
name string
|
||||
filename string
|
||||
desc string
|
||||
}{
|
||||
{"empty", "", "empty filename"},
|
||||
{"single_char", "a", "single character filename"},
|
||||
{"just_dot", ".", "just a dot"},
|
||||
{"double_dot", "..", "double dot"},
|
||||
{"hidden_file", ".hidden", "hidden file"},
|
||||
{"hidden_with_ext", ".hidden.txt", "hidden file with extension"},
|
||||
{"multiple_dots", "file.tar.gz", "multiple extensions"},
|
||||
{"trailing_dot", "file.", "trailing dot"},
|
||||
{"unicode", "файл.txt", "unicode filename"},
|
||||
{"spaces", "my file.txt", "filename with spaces"},
|
||||
{"special_chars", "file@#$.txt", "filename with special characters"},
|
||||
{"very_long", "very_long_filename_with_many_characters_in_it.extension", "very long filename"},
|
||||
{"no_basename", ".gitignore", "dotfile with no basename"},
|
||||
{"case_mixed", "FiLe.ExT", "mixed case"},
|
||||
}
|
||||
|
||||
for _, tc := range edgeCases {
|
||||
t.Run(tc.name, func(_ *testing.T) {
|
||||
// These should not panic
|
||||
_ = registry.IsImage(tc.filename)
|
||||
_ = registry.IsBinary(tc.filename)
|
||||
_ = registry.Language(tc.filename)
|
||||
|
||||
// Global functions should also not panic
|
||||
_ = IsImage(tc.filename)
|
||||
_ = IsBinary(tc.filename)
|
||||
_ = Language(tc.filename)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistry_MinimumExtensionLength tests the minimum extension length requirement.
|
||||
func TestFileTypeRegistryMinimumExtensionLength(t *testing.T) {
|
||||
registry := DefaultRegistry()
|
||||
|
||||
tests := []struct {
|
||||
filename string
|
||||
expected string
|
||||
}{
|
||||
{"", ""}, // Empty filename
|
||||
{"a", ""}, // Single character (less than minExtensionLength)
|
||||
{"ab", ""}, // Two characters, no extension
|
||||
{"a.b", ""}, // Extension too short, but filename too short anyway
|
||||
{"ab.c", "c"}, // Valid: filename >= minExtensionLength and .c is valid extension
|
||||
{"a.go", "go"}, // Valid extension
|
||||
{"ab.py", "python"}, // Valid extension
|
||||
{"a.unknown", ""}, // Valid length but unknown extension
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
result := registry.Language(tt.filename)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Language(%q) = %q, expected %q", tt.filename, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests for performance validation.
|
||||
func BenchmarkFileTypeRegistryIsImage(b *testing.B) {
|
||||
registry := DefaultRegistry()
|
||||
filename := shared.TestFilePNG
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = registry.IsImage(filename)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFileTypeRegistryIsBinary(b *testing.B) {
|
||||
registry := DefaultRegistry()
|
||||
filename := shared.TestFileEXE
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = registry.IsBinary(filename)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFileTypeRegistryLanguage(b *testing.B) {
|
||||
registry := DefaultRegistry()
|
||||
filename := shared.TestFileGo
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = registry.Language(filename)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFileTypeRegistryGlobalFunctions(b *testing.B) {
|
||||
filename := shared.TestFileGo
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = IsImage(filename)
|
||||
_ = IsBinary(filename)
|
||||
_ = Language(filename)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFileTypeRegistryConcurrentAccess(b *testing.B) {
|
||||
filename := shared.TestFileGo
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = IsImage(filename)
|
||||
_ = IsBinary(filename)
|
||||
_ = Language(filename)
|
||||
}
|
||||
})
|
||||
}
|
||||
255
fileproc/filetypes_registry_test.go
Normal file
255
fileproc/filetypes_registry_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// TestFileTypeRegistryAddImageExtension tests adding image extensions.
|
||||
func TestFileTypeRegistryAddImageExtension(t *testing.T) {
|
||||
registry := createModificationTestRegistry()
|
||||
|
||||
testImageExtensionModifications(t, registry)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryAddBinaryExtension tests adding binary extensions.
|
||||
func TestFileTypeRegistryAddBinaryExtension(t *testing.T) {
|
||||
registry := createModificationTestRegistry()
|
||||
|
||||
testBinaryExtensionModifications(t, registry)
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryAddLanguageMapping tests adding language mappings.
|
||||
func TestFileTypeRegistryAddLanguageMapping(t *testing.T) {
|
||||
registry := createModificationTestRegistry()
|
||||
|
||||
testLanguageMappingModifications(t, registry)
|
||||
}
|
||||
|
||||
// createModificationTestRegistry creates a registry for modification tests.
|
||||
func createModificationTestRegistry() *FileTypeRegistry {
|
||||
return &FileTypeRegistry{
|
||||
imageExts: make(map[string]bool),
|
||||
binaryExts: make(map[string]bool),
|
||||
languageMap: make(map[string]string),
|
||||
extCache: make(map[string]string, shared.FileTypeRegistryMaxCacheSize),
|
||||
resultCache: make(map[string]FileTypeResult, shared.FileTypeRegistryMaxCacheSize),
|
||||
maxCacheSize: shared.FileTypeRegistryMaxCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// testImageExtensionModifications tests image extension modifications.
|
||||
func testImageExtensionModifications(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
// Add a new image extension
|
||||
registry.AddImageExtension(".webp")
|
||||
verifyImageExtension(t, registry, ".webp", shared.TestFileWebP, true)
|
||||
|
||||
// Test case-insensitive addition
|
||||
registry.AddImageExtension(".AVIF")
|
||||
verifyImageExtension(t, registry, ".AVIF", "test.avif", true)
|
||||
verifyImageExtension(t, registry, ".AVIF", "test.AVIF", true)
|
||||
|
||||
// Test with dot prefix
|
||||
registry.AddImageExtension("heic")
|
||||
verifyImageExtension(t, registry, "heic", "test.heic", false)
|
||||
|
||||
// Test with proper dot prefix
|
||||
registry.AddImageExtension(".heic")
|
||||
verifyImageExtension(t, registry, ".heic", "test.heic", true)
|
||||
}
|
||||
|
||||
// testBinaryExtensionModifications tests binary extension modifications.
|
||||
func testBinaryExtensionModifications(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
// Add a new binary extension
|
||||
registry.AddBinaryExtension(".custom")
|
||||
verifyBinaryExtension(t, registry, ".custom", "file.custom", true)
|
||||
|
||||
// Test case-insensitive addition
|
||||
registry.AddBinaryExtension(shared.TestExtensionSpecial)
|
||||
verifyBinaryExtension(t, registry, shared.TestExtensionSpecial, "file.special", true)
|
||||
verifyBinaryExtension(t, registry, shared.TestExtensionSpecial, "file.SPECIAL", true)
|
||||
|
||||
// Test with dot prefix
|
||||
registry.AddBinaryExtension("bin")
|
||||
verifyBinaryExtension(t, registry, "bin", "file.bin", false)
|
||||
|
||||
// Test with proper dot prefix
|
||||
registry.AddBinaryExtension(".bin")
|
||||
verifyBinaryExtension(t, registry, ".bin", "file.bin", true)
|
||||
}
|
||||
|
||||
// testLanguageMappingModifications tests language mapping modifications.
|
||||
func testLanguageMappingModifications(t *testing.T, registry *FileTypeRegistry) {
|
||||
t.Helper()
|
||||
|
||||
// Add a new language mapping
|
||||
registry.AddLanguageMapping(".xyz", "CustomLang")
|
||||
verifyLanguageMapping(t, registry, "file.xyz", "CustomLang")
|
||||
|
||||
// Test case-insensitive addition
|
||||
registry.AddLanguageMapping(".ABC", "UpperLang")
|
||||
verifyLanguageMapping(t, registry, "file.abc", "UpperLang")
|
||||
verifyLanguageMapping(t, registry, "file.ABC", "UpperLang")
|
||||
|
||||
// Test with dot prefix (should not work)
|
||||
registry.AddLanguageMapping("nolang", "NoLang")
|
||||
verifyLanguageMappingAbsent(t, registry, "nolang", "file.nolang")
|
||||
|
||||
// Test with proper dot prefix
|
||||
registry.AddLanguageMapping(".nolang", "NoLang")
|
||||
verifyLanguageMapping(t, registry, "file.nolang", "NoLang")
|
||||
|
||||
// Test overriding existing mapping
|
||||
registry.AddLanguageMapping(".xyz", "NewCustomLang")
|
||||
verifyLanguageMapping(t, registry, "file.xyz", "NewCustomLang")
|
||||
}
|
||||
|
||||
// verifyImageExtension verifies image extension behavior.
|
||||
func verifyImageExtension(t *testing.T, registry *FileTypeRegistry, ext, filename string, expected bool) {
|
||||
t.Helper()
|
||||
|
||||
if registry.IsImage(filename) != expected {
|
||||
if expected {
|
||||
t.Errorf("Expected %s to be recognized as image after adding %s", filename, ext)
|
||||
} else {
|
||||
t.Errorf(shared.TestMsgExpectedExtensionWithoutDot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyBinaryExtension verifies binary extension behavior.
|
||||
func verifyBinaryExtension(t *testing.T, registry *FileTypeRegistry, ext, filename string, expected bool) {
|
||||
t.Helper()
|
||||
|
||||
if registry.IsBinary(filename) != expected {
|
||||
if expected {
|
||||
t.Errorf("Expected %s to be recognized as binary after adding %s", filename, ext)
|
||||
} else {
|
||||
t.Errorf(shared.TestMsgExpectedExtensionWithoutDot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyLanguageMapping verifies language mapping behavior.
|
||||
func verifyLanguageMapping(t *testing.T, registry *FileTypeRegistry, filename, expectedLang string) {
|
||||
t.Helper()
|
||||
|
||||
lang := registry.Language(filename)
|
||||
if lang != expectedLang {
|
||||
t.Errorf("Expected %s, got %s for %s", expectedLang, lang, filename)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyLanguageMappingAbsent verifies that a language mapping is absent.
|
||||
func verifyLanguageMappingAbsent(t *testing.T, registry *FileTypeRegistry, _ string, filename string) {
|
||||
t.Helper()
|
||||
|
||||
lang := registry.Language(filename)
|
||||
if lang != "" {
|
||||
t.Errorf(shared.TestMsgExpectedExtensionWithoutDot+", but got %s", lang)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryDefaultRegistryConsistency tests default registry behavior.
|
||||
func TestFileTypeRegistryDefaultRegistryConsistency(t *testing.T) {
|
||||
registry := DefaultRegistry()
|
||||
|
||||
// Test that registry methods work consistently
|
||||
if !registry.IsImage(shared.TestFilePNG) {
|
||||
t.Error("Expected .png to be recognized as image")
|
||||
}
|
||||
if !registry.IsBinary(shared.TestFileEXE) {
|
||||
t.Error("Expected .exe to be recognized as binary")
|
||||
}
|
||||
if lang := registry.Language(shared.TestFileGo); lang != "go" {
|
||||
t.Errorf("Expected go, got %s", lang)
|
||||
}
|
||||
|
||||
// Test that multiple calls return consistent results
|
||||
for i := 0; i < 5; i++ {
|
||||
if !registry.IsImage(shared.TestFileJPG) {
|
||||
t.Errorf("Iteration %d: Expected .jpg to be recognized as image", i)
|
||||
}
|
||||
if registry.IsBinary(shared.TestFileTXT) {
|
||||
t.Errorf("Iteration %d: Expected .txt to not be recognized as binary", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryGetStats tests the GetStats method.
|
||||
func TestFileTypeRegistryGetStats(t *testing.T) {
|
||||
// Ensure clean, isolated state
|
||||
ResetRegistryForTesting()
|
||||
t.Cleanup(ResetRegistryForTesting)
|
||||
registry := DefaultRegistry()
|
||||
|
||||
// Call some methods to populate cache and update stats
|
||||
registry.IsImage(shared.TestFilePNG)
|
||||
registry.IsBinary(shared.TestFileEXE)
|
||||
registry.Language(shared.TestFileGo)
|
||||
// Repeat to generate cache hits
|
||||
registry.IsImage(shared.TestFilePNG)
|
||||
registry.IsBinary(shared.TestFileEXE)
|
||||
registry.Language(shared.TestFileGo)
|
||||
|
||||
// Get stats
|
||||
stats := registry.Stats()
|
||||
|
||||
// Verify stats structure - all values are uint64 and therefore non-negative by definition
|
||||
// We can verify they exist and are properly initialized
|
||||
|
||||
// Test that stats include our calls
|
||||
if stats.TotalLookups < 6 { // We made at least 6 calls above
|
||||
t.Errorf("Expected at least 6 total lookups, got %d", stats.TotalLookups)
|
||||
}
|
||||
|
||||
// Total lookups should equal hits + misses
|
||||
if stats.TotalLookups != stats.CacheHits+stats.CacheMisses {
|
||||
t.Errorf("Total lookups (%d) should equal hits (%d) + misses (%d)",
|
||||
stats.TotalLookups, stats.CacheHits, stats.CacheMisses)
|
||||
}
|
||||
// With repeated lookups we should see some cache hits
|
||||
if stats.CacheHits == 0 {
|
||||
t.Error("Expected some cache hits after repeated lookups")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileTypeRegistryGetCacheInfo tests the GetCacheInfo method.
|
||||
func TestFileTypeRegistryGetCacheInfo(t *testing.T) {
|
||||
// Ensure clean, isolated state
|
||||
ResetRegistryForTesting()
|
||||
t.Cleanup(ResetRegistryForTesting)
|
||||
registry := DefaultRegistry()
|
||||
|
||||
// Call some methods to populate cache
|
||||
registry.IsImage("test1.png")
|
||||
registry.IsBinary("test2.exe")
|
||||
registry.Language("test3.go")
|
||||
registry.IsImage("test4.jpg")
|
||||
registry.IsBinary("test5.dll")
|
||||
|
||||
// Get cache info
|
||||
extCacheSize, resultCacheSize, maxCacheSize := registry.CacheInfo()
|
||||
|
||||
// Verify cache info
|
||||
if extCacheSize < 0 {
|
||||
t.Error("Expected non-negative extension cache size")
|
||||
}
|
||||
if resultCacheSize < 0 {
|
||||
t.Error("Expected non-negative result cache size")
|
||||
}
|
||||
if maxCacheSize <= 0 {
|
||||
t.Error("Expected positive max cache size")
|
||||
}
|
||||
|
||||
// We should have some cache entries from our calls
|
||||
totalCacheSize := extCacheSize + resultCacheSize
|
||||
if totalCacheSize == 0 {
|
||||
t.Error("Expected some cache entries after multiple calls")
|
||||
}
|
||||
}
|
||||
30
fileproc/formats.go
Normal file
30
fileproc/formats.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
// FileData represents a single file's path and content.
|
||||
type FileData struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Content string `json:"content" yaml:"content"`
|
||||
Language string `json:"language" yaml:"language"`
|
||||
}
|
||||
|
||||
// OutputData represents the full output structure.
|
||||
type OutputData struct {
|
||||
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty" yaml:"suffix,omitempty"`
|
||||
Files []FileData `json:"files" yaml:"files"`
|
||||
}
|
||||
|
||||
// FormatWriter defines the interface for format-specific writers.
|
||||
type FormatWriter interface {
|
||||
Start(prefix, suffix string) error
|
||||
WriteFile(req WriteRequest) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// detectLanguage tries to infer the code block language from the file extension.
|
||||
func detectLanguage(filePath string) string {
|
||||
registry := DefaultRegistry()
|
||||
|
||||
return registry.Language(filePath)
|
||||
}
|
||||
70
fileproc/ignore_rules.go
Normal file
70
fileproc/ignore_rules.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
)
|
||||
|
||||
// ignoreRule holds an ignore matcher along with the base directory where it was loaded.
|
||||
type ignoreRule struct {
|
||||
gi *ignore.GitIgnore
|
||||
base string
|
||||
}
|
||||
|
||||
// loadIgnoreRules loads ignore rules from the current directory and combines them with parent rules.
|
||||
func loadIgnoreRules(currentDir string, parentRules []ignoreRule) []ignoreRule {
|
||||
// Pre-allocate for parent rules plus possible .gitignore and .ignore
|
||||
const expectedIgnoreFiles = 2
|
||||
rules := make([]ignoreRule, 0, len(parentRules)+expectedIgnoreFiles)
|
||||
rules = append(rules, parentRules...)
|
||||
|
||||
// Check for .gitignore and .ignore files in the current directory.
|
||||
for _, fileName := range []string{".gitignore", ".ignore"} {
|
||||
if rule := tryLoadIgnoreFile(currentDir, fileName); rule != nil {
|
||||
rules = append(rules, *rule)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
// tryLoadIgnoreFile attempts to load an ignore file from the given directory.
|
||||
func tryLoadIgnoreFile(dir, fileName string) *ignoreRule {
|
||||
ignorePath := filepath.Join(dir, fileName)
|
||||
if info, err := os.Stat(ignorePath); err == nil && !info.IsDir() {
|
||||
//nolint:errcheck // Regex compile error handled by validation, safe to ignore here
|
||||
if gi, err := ignore.CompileIgnoreFile(ignorePath); err == nil {
|
||||
return &ignoreRule{
|
||||
base: dir,
|
||||
gi: gi,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchesIgnoreRules checks if a path matches any of the ignore rules.
|
||||
func matchesIgnoreRules(fullPath string, rules []ignoreRule) bool {
|
||||
for _, rule := range rules {
|
||||
if matchesRule(fullPath, rule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesRule checks if a path matches a specific ignore rule.
|
||||
func matchesRule(fullPath string, rule ignoreRule) bool {
|
||||
// Compute the path relative to the base where the ignore rule was defined.
|
||||
rel, err := filepath.Rel(rule.base, fullPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// If the rule matches, skip this entry.
|
||||
return rule.gi.MatchesPath(rel)
|
||||
}
|
||||
169
fileproc/json_writer.go
Normal file
169
fileproc/json_writer.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// JSONWriter handles JSON format output with streaming support.
|
||||
type JSONWriter struct {
|
||||
outFile *os.File
|
||||
firstFile bool
|
||||
}
|
||||
|
||||
// NewJSONWriter creates a new JSON writer.
|
||||
func NewJSONWriter(outFile *os.File) *JSONWriter {
|
||||
return &JSONWriter{
|
||||
outFile: outFile,
|
||||
firstFile: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Start writes the JSON header.
|
||||
func (w *JSONWriter) Start(prefix, suffix string) error {
|
||||
// Start JSON structure
|
||||
if _, err := w.outFile.WriteString(`{"prefix":"`); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write JSON start")
|
||||
}
|
||||
|
||||
// Write escaped prefix
|
||||
escapedPrefix := shared.EscapeForJSON(prefix)
|
||||
if err := shared.WriteWithErrorWrap(w.outFile, escapedPrefix, "failed to write JSON prefix", ""); err != nil {
|
||||
return fmt.Errorf("writing JSON prefix: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.outFile.WriteString(`","suffix":"`); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write JSON middle")
|
||||
}
|
||||
|
||||
// Write escaped suffix
|
||||
escapedSuffix := shared.EscapeForJSON(suffix)
|
||||
if err := shared.WriteWithErrorWrap(w.outFile, escapedSuffix, "failed to write JSON suffix", ""); err != nil {
|
||||
return fmt.Errorf("writing JSON suffix: %w", err)
|
||||
}
|
||||
|
||||
if _, err := w.outFile.WriteString(`","files":[`); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write JSON files start")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteFile writes a file entry in JSON format.
|
||||
func (w *JSONWriter) WriteFile(req WriteRequest) error {
|
||||
if !w.firstFile {
|
||||
if _, err := w.outFile.WriteString(","); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write JSON separator")
|
||||
}
|
||||
}
|
||||
w.firstFile = false
|
||||
|
||||
if req.IsStream {
|
||||
return w.writeStreaming(req)
|
||||
}
|
||||
|
||||
return w.writeInline(req)
|
||||
}
|
||||
|
||||
// Close writes the JSON footer.
|
||||
func (w *JSONWriter) Close() error {
|
||||
// Close JSON structure
|
||||
if _, err := w.outFile.WriteString("]}"); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write JSON end")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStreaming writes a large file as JSON in streaming chunks.
|
||||
func (w *JSONWriter) writeStreaming(req WriteRequest) error {
|
||||
defer shared.SafeCloseReader(req.Reader, req.Path)
|
||||
|
||||
language := detectLanguage(req.Path)
|
||||
|
||||
// Write file start
|
||||
escapedPath := shared.EscapeForJSON(req.Path)
|
||||
if _, err := fmt.Fprintf(w.outFile, `{"path":"%s","language":"%s","content":"`, escapedPath, language); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write JSON file start",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
// Stream content with JSON escaping
|
||||
if err := w.streamJSONContent(req.Reader, req.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write file end
|
||||
if _, err := w.outFile.WriteString(`"}`); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write JSON file end",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeInline writes a small file directly as JSON.
|
||||
func (w *JSONWriter) writeInline(req WriteRequest) error {
|
||||
language := detectLanguage(req.Path)
|
||||
fileData := FileData{
|
||||
Path: req.Path,
|
||||
Content: req.Content,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(fileData)
|
||||
if err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingEncode,
|
||||
"failed to marshal JSON",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
if _, err := w.outFile.Write(encoded); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write JSON file",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamJSONContent streams content with JSON escaping.
|
||||
func (w *JSONWriter) streamJSONContent(reader io.Reader, path string) error {
|
||||
if err := shared.StreamContent(
|
||||
reader, w.outFile, shared.FileProcessingStreamChunkSize, path, func(chunk []byte) []byte {
|
||||
escaped := shared.EscapeForJSON(string(chunk))
|
||||
|
||||
return []byte(escaped)
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("streaming JSON content: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startJSONWriter handles JSON format output with streaming support.
|
||||
func startJSONWriter(outFile *os.File, writeCh <-chan WriteRequest, done chan<- struct{}, prefix, suffix string) {
|
||||
startFormatWriter(outFile, writeCh, done, prefix, suffix, func(f *os.File) FormatWriter {
|
||||
return NewJSONWriter(f)
|
||||
})
|
||||
}
|
||||
113
fileproc/markdown_writer.go
Normal file
113
fileproc/markdown_writer.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// MarkdownWriter handles Markdown format output with streaming support.
|
||||
type MarkdownWriter struct {
|
||||
outFile *os.File
|
||||
suffix string
|
||||
}
|
||||
|
||||
// NewMarkdownWriter creates a new markdown writer.
|
||||
func NewMarkdownWriter(outFile *os.File) *MarkdownWriter {
|
||||
return &MarkdownWriter{outFile: outFile}
|
||||
}
|
||||
|
||||
// Start writes the markdown header and stores the suffix for later use.
|
||||
func (w *MarkdownWriter) Start(prefix, suffix string) error {
|
||||
// Store suffix for use in Close method
|
||||
w.suffix = suffix
|
||||
|
||||
if prefix != "" {
|
||||
if _, err := fmt.Fprintf(w.outFile, "# %s\n\n", prefix); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write prefix")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteFile writes a file entry in Markdown format.
|
||||
func (w *MarkdownWriter) WriteFile(req WriteRequest) error {
|
||||
if req.IsStream {
|
||||
return w.writeStreaming(req)
|
||||
}
|
||||
|
||||
return w.writeInline(req)
|
||||
}
|
||||
|
||||
// Close writes the markdown footer using the suffix stored in Start.
|
||||
func (w *MarkdownWriter) Close() error {
|
||||
if w.suffix != "" {
|
||||
if _, err := fmt.Fprintf(w.outFile, "\n# %s\n", w.suffix); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write suffix")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStreaming writes a large file in streaming chunks.
|
||||
func (w *MarkdownWriter) writeStreaming(req WriteRequest) error {
|
||||
defer shared.SafeCloseReader(req.Reader, req.Path)
|
||||
|
||||
language := detectLanguage(req.Path)
|
||||
|
||||
// Write file header
|
||||
if _, err := fmt.Fprintf(w.outFile, "## File: `%s`\n```%s\n", req.Path, language); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write file header",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
// Stream file content in chunks
|
||||
chunkSize := shared.FileProcessingStreamChunkSize
|
||||
if err := shared.StreamContent(req.Reader, w.outFile, chunkSize, req.Path, nil); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "streaming content for markdown file")
|
||||
}
|
||||
|
||||
// Write file footer
|
||||
if _, err := w.outFile.WriteString("\n```\n\n"); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write file footer",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeInline writes a small file directly from content.
|
||||
func (w *MarkdownWriter) writeInline(req WriteRequest) error {
|
||||
language := detectLanguage(req.Path)
|
||||
formatted := fmt.Sprintf("## File: `%s`\n```%s\n%s\n```\n\n", req.Path, language, req.Content)
|
||||
|
||||
if _, err := w.outFile.WriteString(formatted); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write inline content",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startMarkdownWriter handles Markdown format output with streaming support.
|
||||
func startMarkdownWriter(outFile *os.File, writeCh <-chan WriteRequest, done chan<- struct{}, prefix, suffix string) {
|
||||
startFormatWriter(outFile, writeCh, done, prefix, suffix, func(f *os.File) FormatWriter {
|
||||
return NewMarkdownWriter(f)
|
||||
})
|
||||
}
|
||||
@@ -2,35 +2,460 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// WriteRequest represents the content to be written.
|
||||
type WriteRequest struct {
|
||||
Path string
|
||||
Content string
|
||||
Path string
|
||||
Content string
|
||||
IsStream bool
|
||||
Reader io.Reader
|
||||
Size int64 // File size for streaming files
|
||||
}
|
||||
|
||||
// FileProcessor handles file processing operations.
|
||||
type FileProcessor struct {
|
||||
rootPath string
|
||||
sizeLimit int64
|
||||
resourceMonitor *ResourceMonitor
|
||||
}
|
||||
|
||||
// NewFileProcessor creates a new file processor.
|
||||
func NewFileProcessor(rootPath string) *FileProcessor {
|
||||
return &FileProcessor{
|
||||
rootPath: rootPath,
|
||||
sizeLimit: config.FileSizeLimit(),
|
||||
resourceMonitor: NewResourceMonitor(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileProcessorWithMonitor creates a new file processor with a shared resource monitor.
|
||||
func NewFileProcessorWithMonitor(rootPath string, monitor *ResourceMonitor) *FileProcessor {
|
||||
return &FileProcessor{
|
||||
rootPath: rootPath,
|
||||
sizeLimit: config.FileSizeLimit(),
|
||||
resourceMonitor: monitor,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessFile reads the file at filePath and sends a formatted output to outCh.
|
||||
// It automatically chooses between loading the entire file or streaming based on file size.
|
||||
func ProcessFile(filePath string, outCh chan<- WriteRequest, rootPath string) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to read file %s: %v", filePath, err)
|
||||
return
|
||||
processor := NewFileProcessor(rootPath)
|
||||
ctx := context.Background()
|
||||
if err := processor.ProcessWithContext(ctx, filePath, outCh); err != nil {
|
||||
shared.LogErrorf(err, shared.FileProcessingMsgFailedToProcess, filePath)
|
||||
}
|
||||
|
||||
// Compute path relative to rootPath, so /a/b/c/d.c becomes c/d.c
|
||||
relPath, err := filepath.Rel(rootPath, filePath)
|
||||
if err != nil {
|
||||
// Fallback if something unexpected happens
|
||||
relPath = filePath
|
||||
}
|
||||
|
||||
// Format: separator, then relative path, then content
|
||||
formatted := fmt.Sprintf("\n---\n%s\n%s\n", relPath, string(content))
|
||||
outCh <- WriteRequest{Path: relPath, Content: formatted}
|
||||
}
|
||||
|
||||
// ProcessFileWithMonitor processes a file using a shared resource monitor.
|
||||
func ProcessFileWithMonitor(
|
||||
ctx context.Context,
|
||||
filePath string,
|
||||
outCh chan<- WriteRequest,
|
||||
rootPath string,
|
||||
monitor *ResourceMonitor,
|
||||
) error {
|
||||
if monitor == nil {
|
||||
monitor = NewResourceMonitor()
|
||||
}
|
||||
processor := NewFileProcessorWithMonitor(rootPath, monitor)
|
||||
|
||||
return processor.ProcessWithContext(ctx, filePath, outCh)
|
||||
}
|
||||
|
||||
// Process handles file processing with the configured settings.
|
||||
func (p *FileProcessor) Process(filePath string, outCh chan<- WriteRequest) {
|
||||
ctx := context.Background()
|
||||
if err := p.ProcessWithContext(ctx, filePath, outCh); err != nil {
|
||||
shared.LogErrorf(err, shared.FileProcessingMsgFailedToProcess, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessWithContext handles file processing with context and resource monitoring.
|
||||
func (p *FileProcessor) ProcessWithContext(ctx context.Context, filePath string, outCh chan<- WriteRequest) error {
|
||||
// Create file processing context with timeout
|
||||
fileCtx, fileCancel := p.resourceMonitor.CreateFileProcessingContext(ctx)
|
||||
defer fileCancel()
|
||||
|
||||
// Wait for rate limiting
|
||||
if err := p.resourceMonitor.WaitForRateLimit(fileCtx); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"file processing timeout during rate limiting",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "File processing timeout during rate limiting: %s", filePath)
|
||||
|
||||
return structErr
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate file and check resource limits
|
||||
fileInfo, err := p.validateFileWithLimits(fileCtx, filePath)
|
||||
if err != nil {
|
||||
return err // Error already logged
|
||||
}
|
||||
|
||||
// Acquire read slot for concurrent processing
|
||||
if err := p.resourceMonitor.AcquireReadSlot(fileCtx); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"file processing timeout waiting for read slot",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "File processing timeout waiting for read slot: %s", filePath)
|
||||
|
||||
return structErr
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
defer p.resourceMonitor.ReleaseReadSlot()
|
||||
|
||||
// Check hard memory limits before processing
|
||||
if err := p.resourceMonitor.CheckHardMemoryLimit(); err != nil {
|
||||
shared.LogErrorf(err, "Hard memory limit check failed for file: %s", filePath)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath := p.getRelativePath(filePath)
|
||||
|
||||
// Process file with timeout
|
||||
processStart := time.Now()
|
||||
|
||||
// Choose processing strategy based on file size
|
||||
if fileInfo.Size() <= shared.FileProcessingStreamThreshold {
|
||||
err = p.processInMemoryWithContext(fileCtx, filePath, relPath, outCh)
|
||||
} else {
|
||||
err = p.processStreamingWithContext(fileCtx, filePath, relPath, outCh, fileInfo.Size())
|
||||
}
|
||||
|
||||
// Only record success if processing completed without error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Record successful processing only on success path
|
||||
p.resourceMonitor.RecordFileProcessed(fileInfo.Size())
|
||||
logger := shared.GetLogger()
|
||||
logger.Debugf("File processed in %v: %s", time.Since(processStart), filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFileWithLimits checks if the file can be processed with resource limits.
|
||||
func (p *FileProcessor) validateFileWithLimits(ctx context.Context, filePath string) (os.FileInfo, error) {
|
||||
// Check context cancellation
|
||||
if err := shared.CheckContextCancellation(ctx, "file validation"); err != nil {
|
||||
return nil, fmt.Errorf("context check during file validation: %w", err)
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
structErr := shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
"failed to stat file",
|
||||
).WithFilePath(filePath)
|
||||
shared.LogErrorf(structErr, "Failed to stat file %s", filePath)
|
||||
|
||||
return nil, structErr
|
||||
}
|
||||
|
||||
// Check traditional size limit
|
||||
if fileInfo.Size() > p.sizeLimit {
|
||||
c := map[string]any{
|
||||
"file_size": fileInfo.Size(),
|
||||
"size_limit": p.sizeLimit,
|
||||
}
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationSize,
|
||||
fmt.Sprintf(shared.FileProcessingMsgSizeExceeds, fileInfo.Size(), p.sizeLimit),
|
||||
filePath,
|
||||
c,
|
||||
)
|
||||
shared.LogErrorf(structErr, "Skipping large file %s", filePath)
|
||||
|
||||
return nil, structErr
|
||||
}
|
||||
|
||||
// Check resource limits
|
||||
if err := p.resourceMonitor.ValidateFileProcessing(filePath, fileInfo.Size()); err != nil {
|
||||
shared.LogErrorf(err, "Resource limit validation failed for file: %s", filePath)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// getRelativePath computes the path relative to rootPath.
|
||||
func (p *FileProcessor) getRelativePath(filePath string) string {
|
||||
relPath, err := filepath.Rel(p.rootPath, filePath)
|
||||
if err != nil {
|
||||
return filePath // Fallback
|
||||
}
|
||||
|
||||
return relPath
|
||||
}
|
||||
|
||||
// processInMemoryWithContext loads the entire file into memory with context awareness.
|
||||
func (p *FileProcessor) processInMemoryWithContext(
|
||||
ctx context.Context,
|
||||
filePath, relPath string,
|
||||
outCh chan<- WriteRequest,
|
||||
) error {
|
||||
// Check context before reading
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"file processing canceled",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "File processing canceled: %s", filePath)
|
||||
|
||||
return structErr
|
||||
default:
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filePath) // #nosec G304 - filePath is validated by walker
|
||||
if err != nil {
|
||||
structErr := shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingFileRead,
|
||||
"failed to read file",
|
||||
).WithFilePath(filePath)
|
||||
shared.LogErrorf(structErr, "Failed to read file %s", filePath)
|
||||
|
||||
return structErr
|
||||
}
|
||||
|
||||
// Check context again after reading
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"file processing canceled after read",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "File processing canceled after read: %s", filePath)
|
||||
|
||||
return structErr
|
||||
default:
|
||||
}
|
||||
|
||||
// Try to send the result, but respect context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"file processing canceled before output",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "File processing canceled before output: %s", filePath)
|
||||
|
||||
return structErr
|
||||
case outCh <- WriteRequest{
|
||||
Path: relPath,
|
||||
Content: p.formatContent(relPath, string(content)),
|
||||
IsStream: false,
|
||||
Size: int64(len(content)),
|
||||
}:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processStreamingWithContext creates a streaming reader for large files with context awareness.
|
||||
func (p *FileProcessor) processStreamingWithContext(
|
||||
ctx context.Context,
|
||||
filePath, relPath string,
|
||||
outCh chan<- WriteRequest,
|
||||
size int64,
|
||||
) error {
|
||||
// Check context before creating reader
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"streaming processing canceled",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "Streaming processing canceled: %s", filePath)
|
||||
|
||||
return structErr
|
||||
default:
|
||||
}
|
||||
|
||||
reader := p.createStreamReaderWithContext(ctx, filePath, relPath)
|
||||
if reader == nil {
|
||||
// Error already logged, create and return error
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingFileRead,
|
||||
"failed to create stream reader",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to send the result, but respect context cancellation
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
structErr := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"streaming processing canceled before output",
|
||||
filePath,
|
||||
nil,
|
||||
)
|
||||
shared.LogErrorf(structErr, "Streaming processing canceled before output: %s", filePath)
|
||||
|
||||
return structErr
|
||||
case outCh <- WriteRequest{
|
||||
Path: relPath,
|
||||
Content: "", // Empty since content is in Reader
|
||||
IsStream: true,
|
||||
Reader: reader,
|
||||
Size: size,
|
||||
}:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createStreamReaderWithContext creates a reader that combines header and file content with context awareness.
|
||||
func (p *FileProcessor) createStreamReaderWithContext(
|
||||
ctx context.Context, filePath, relPath string,
|
||||
) io.Reader {
|
||||
// Check context before opening file
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
file, err := os.Open(filePath) // #nosec G304 - filePath is validated by walker
|
||||
if err != nil {
|
||||
structErr := shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingFileRead,
|
||||
"failed to open file for streaming",
|
||||
).WithFilePath(filePath)
|
||||
shared.LogErrorf(structErr, "Failed to open file for streaming %s", filePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
header := p.formatHeader(relPath)
|
||||
|
||||
return newHeaderFileReader(header, file)
|
||||
}
|
||||
|
||||
// formatContent formats the file content with header.
|
||||
func (p *FileProcessor) formatContent(relPath, content string) string {
|
||||
return fmt.Sprintf("\n---\n%s\n%s\n", relPath, content)
|
||||
}
|
||||
|
||||
// formatHeader creates a reader for the file header.
|
||||
func (p *FileProcessor) formatHeader(relPath string) io.Reader {
|
||||
return strings.NewReader(fmt.Sprintf("\n---\n%s\n", relPath))
|
||||
}
|
||||
|
||||
// headerFileReader wraps a MultiReader and closes the file when EOF is reached.
|
||||
type headerFileReader struct {
|
||||
reader io.Reader
|
||||
file *os.File
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// newHeaderFileReader creates a new headerFileReader.
|
||||
func newHeaderFileReader(header io.Reader, file *os.File) *headerFileReader {
|
||||
return &headerFileReader{
|
||||
reader: io.MultiReader(header, file),
|
||||
file: file,
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements io.Reader and closes the file on EOF.
|
||||
func (r *headerFileReader) Read(p []byte) (n int, err error) {
|
||||
n, err = r.reader.Read(p)
|
||||
if err == io.EOF {
|
||||
r.closeFile()
|
||||
// EOF is a sentinel value that must be passed through unchanged for io.Reader interface
|
||||
return n, err //nolint:wrapcheck // EOF must not be wrapped
|
||||
}
|
||||
if err != nil {
|
||||
return n, shared.WrapError(
|
||||
err, shared.ErrorTypeIO, shared.CodeIORead,
|
||||
"failed to read from header file reader",
|
||||
)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// closeFile closes the file once.
|
||||
func (r *headerFileReader) closeFile() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.closed && r.file != nil {
|
||||
if err := r.file.Close(); err != nil {
|
||||
shared.LogError("Failed to close file", err)
|
||||
}
|
||||
r.closed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Close implements io.Closer and ensures the underlying file is closed.
|
||||
// This allows explicit cleanup when consumers stop reading before EOF.
|
||||
func (r *headerFileReader) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.closed || r.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.file.Close()
|
||||
if err != nil {
|
||||
shared.LogError("Failed to close file", err)
|
||||
}
|
||||
r.closed = true
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
package fileproc
|
||||
package fileproc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
// writeTempConfig creates a temporary config file with the given YAML content
|
||||
// and returns the directory path containing the config file.
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("Failed to create temp config: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestProcessFile(t *testing.T) {
|
||||
// Reset and load default config to ensure proper file size limits
|
||||
testutil.ResetViperConfig(t, "")
|
||||
// Create a temporary file with known content.
|
||||
tmpFile, err := os.CreateTemp("", "testfile")
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "testfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,23 +53,20 @@ func TestProcessFile(t *testing.T) {
|
||||
errTmpFile := tmpFile.Close()
|
||||
if errTmpFile != nil {
|
||||
t.Fatal(errTmpFile)
|
||||
return
|
||||
}
|
||||
|
||||
ch := make(chan WriteRequest, 1)
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ProcessFile(tmpFile.Name(), ch, "")
|
||||
}()
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
fileproc.ProcessFile(tmpFile.Name(), ch, "")
|
||||
})
|
||||
|
||||
var result string
|
||||
for req := range ch {
|
||||
result = req.Content
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if !strings.Contains(result, tmpFile.Name()) {
|
||||
t.Errorf("Output does not contain file path: %s", tmpFile.Name())
|
||||
@@ -52,3 +75,686 @@ func TestProcessFile(t *testing.T) {
|
||||
t.Errorf("Output does not contain file content: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFileProcessorWithMonitor tests processor creation with resource monitor.
|
||||
func TestNewFileProcessorWithMonitor(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Create a resource monitor
|
||||
monitor := fileproc.NewResourceMonitor()
|
||||
defer monitor.Close()
|
||||
|
||||
processor := fileproc.NewFileProcessorWithMonitor("test_source", monitor)
|
||||
if processor == nil {
|
||||
t.Error("Expected processor but got nil")
|
||||
}
|
||||
|
||||
// Exercise the processor to verify monitor integration
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "monitor_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString("test content"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
writeCh := make(chan fileproc.WriteRequest, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(writeCh)
|
||||
if err := processor.ProcessWithContext(ctx, tmpFile.Name(), writeCh); err != nil {
|
||||
t.Errorf("ProcessWithContext failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Drain channel first to avoid deadlock if producer sends multiple requests
|
||||
requestCount := 0
|
||||
for range writeCh {
|
||||
requestCount++
|
||||
}
|
||||
|
||||
// Wait for goroutine to finish after channel is drained
|
||||
wg.Wait()
|
||||
|
||||
if requestCount == 0 {
|
||||
t.Error("Expected at least one write request from processor")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessFileWithMonitor tests file processing with resource monitoring.
|
||||
func TestProcessFileWithMonitor(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Create temporary file
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "testfile_monitor_*")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(tmpFile.Name()); err != nil {
|
||||
t.Logf("Failed to remove temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
content := "Test content with monitor"
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToWriteContent, err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCloseFile, err)
|
||||
}
|
||||
|
||||
// Create resource monitor
|
||||
monitor := fileproc.NewResourceMonitor()
|
||||
defer monitor.Close()
|
||||
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
ctx := context.Background()
|
||||
|
||||
// Test ProcessFileWithMonitor
|
||||
var wg sync.WaitGroup
|
||||
var result string
|
||||
|
||||
// Start reader goroutine first to prevent deadlock
|
||||
wg.Go(func() {
|
||||
for req := range ch {
|
||||
result = req.Content
|
||||
}
|
||||
})
|
||||
|
||||
// Process the file
|
||||
err = fileproc.ProcessFileWithMonitor(ctx, tmpFile.Name(), ch, "", monitor)
|
||||
close(ch)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessFileWithMonitor failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for reader to finish
|
||||
wg.Wait()
|
||||
|
||||
if !strings.Contains(result, content) {
|
||||
t.Error("Expected content not found in processed result")
|
||||
}
|
||||
}
|
||||
|
||||
const testContent = "package main\nfunc main() {}\n"
|
||||
|
||||
// TestProcess tests the basic Process function.
|
||||
func TestProcess(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Create temporary directory
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test file with .go extension
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
content := testContent
|
||||
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
ch := make(chan fileproc.WriteRequest, 10)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
// Process the specific file, not the directory
|
||||
processor.Process(testFile, ch)
|
||||
})
|
||||
|
||||
// Collect results
|
||||
results := make([]fileproc.WriteRequest, 0, 1) // Pre-allocate with expected capacity
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(results) == 0 {
|
||||
t.Error("Expected at least one processed file")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Find our test file in results
|
||||
found := false
|
||||
for _, req := range results {
|
||||
if strings.Contains(req.Path, shared.TestFileGo) && strings.Contains(req.Content, content) {
|
||||
found = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("Test file not found in processed results")
|
||||
}
|
||||
}
|
||||
|
||||
// createLargeTestFile creates a large test file for streaming tests.
|
||||
func createLargeTestFile(t *testing.T) *os.File {
|
||||
t.Helper()
|
||||
|
||||
tmpFile, err := os.CreateTemp(t.TempDir(), "large_file_*.go")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
lineContent := "// Repeated comment line to exceed streaming threshold\n"
|
||||
repeatCount := (1048576 / len(lineContent)) + 1000
|
||||
largeContent := strings.Repeat(lineContent, repeatCount)
|
||||
|
||||
if _, err := tmpFile.WriteString(largeContent); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToWriteContent, err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCloseFile, err)
|
||||
}
|
||||
|
||||
t.Logf("Created test file size: %d bytes", len(largeContent))
|
||||
|
||||
return tmpFile
|
||||
}
|
||||
|
||||
// processFileForStreaming processes a file and returns streaming/inline requests.
|
||||
func processFileForStreaming(t *testing.T, filePath string) (streamingReq, inlineReq *fileproc.WriteRequest) {
|
||||
t.Helper()
|
||||
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
fileproc.ProcessFile(filePath, ch, "")
|
||||
})
|
||||
|
||||
var streamingRequest *fileproc.WriteRequest
|
||||
var inlineRequest *fileproc.WriteRequest
|
||||
|
||||
for req := range ch {
|
||||
if req.IsStream {
|
||||
reqCopy := req
|
||||
streamingRequest = &reqCopy
|
||||
} else {
|
||||
reqCopy := req
|
||||
inlineRequest = &reqCopy
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return streamingRequest, inlineRequest
|
||||
}
|
||||
|
||||
// validateStreamingRequest validates a streaming request.
|
||||
func validateStreamingRequest(t *testing.T, streamingRequest *fileproc.WriteRequest, tmpFile *os.File) {
|
||||
t.Helper()
|
||||
|
||||
if streamingRequest.Reader == nil {
|
||||
t.Error("Expected reader in streaming request")
|
||||
}
|
||||
if streamingRequest.Content != "" {
|
||||
t.Error("Expected empty content for streaming request")
|
||||
}
|
||||
|
||||
buffer := make([]byte, 1024)
|
||||
n, err := streamingRequest.Reader.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Errorf("Failed to read from streaming request: %v", err)
|
||||
}
|
||||
|
||||
content := string(buffer[:n])
|
||||
if !strings.Contains(content, tmpFile.Name()) {
|
||||
t.Error("Expected file path in streamed header content")
|
||||
}
|
||||
|
||||
t.Log("Successfully triggered streaming for large file and tested reader")
|
||||
}
|
||||
|
||||
// TestProcessorStreamingIntegration tests streaming functionality in processor.
|
||||
func TestProcessorStreamingIntegration(t *testing.T) {
|
||||
configDir := writeTempConfig(t, `
|
||||
max_file_size_mb: 0.001
|
||||
streaming_threshold_mb: 0.0001
|
||||
`)
|
||||
testutil.ResetViperConfig(t, configDir)
|
||||
|
||||
tmpFile := createLargeTestFile(t)
|
||||
defer func() {
|
||||
if err := os.Remove(tmpFile.Name()); err != nil {
|
||||
t.Logf("Failed to remove temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
streamingRequest, inlineRequest := processFileForStreaming(t, tmpFile.Name())
|
||||
|
||||
if streamingRequest == nil && inlineRequest == nil {
|
||||
t.Error("Expected either streaming or inline request but got none")
|
||||
}
|
||||
|
||||
if streamingRequest != nil {
|
||||
validateStreamingRequest(t, streamingRequest, tmpFile)
|
||||
} else {
|
||||
t.Log("File processed inline instead of streaming")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessorContextCancellation tests context cancellation during processing.
|
||||
func TestProcessorContextCancellation(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Create temporary directory with files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create multiple test files
|
||||
for i := 0; i < 5; i++ {
|
||||
testFile := filepath.Join(tmpDir, fmt.Sprintf("test%d.go", i))
|
||||
content := testContent
|
||||
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor("test_source")
|
||||
ch := make(chan fileproc.WriteRequest, 10)
|
||||
|
||||
// Use ProcessWithContext with immediate cancellation
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
// Error is expected due to cancellation
|
||||
if err := processor.ProcessWithContext(ctx, tmpDir, ch); err != nil {
|
||||
// Log error for debugging, but don't fail test since cancellation is expected
|
||||
t.Logf("Expected error due to cancellation: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Collect results - should be minimal due to cancellation
|
||||
results := make([]fileproc.WriteRequest, 0, 1) // Pre-allocate with expected capacity
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// With immediate cancellation, we might get 0 results
|
||||
// This tests that cancellation is respected
|
||||
t.Logf("Processed %d files with immediate cancellation", len(results))
|
||||
}
|
||||
|
||||
// TestProcessorValidationEdgeCases tests edge cases in file validation.
|
||||
func TestProcessorValidationEdgeCases(t *testing.T) {
|
||||
configDir := writeTempConfig(t, `
|
||||
max_file_size_mb: 0.001 # 1KB limit for testing
|
||||
`)
|
||||
testutil.ResetViperConfig(t, configDir)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Test case 1: Non-existent file
|
||||
nonExistentFile := filepath.Join(tmpDir, "does-not-exist.go")
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
processor.Process(nonExistentFile, ch)
|
||||
})
|
||||
|
||||
results := make([]fileproc.WriteRequest, 0)
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should get no results due to file not existing
|
||||
if len(results) > 0 {
|
||||
t.Error("Expected no results for non-existent file")
|
||||
}
|
||||
|
||||
// Test case 2: File that exceeds size limit
|
||||
largeFile := filepath.Join(tmpDir, "large.go")
|
||||
largeContent := strings.Repeat("// Large file content\n", 100) // > 1KB
|
||||
if err := os.WriteFile(largeFile, []byte(largeContent), 0o600); err != nil {
|
||||
t.Fatalf("Failed to create large file: %v", err)
|
||||
}
|
||||
|
||||
ch2 := make(chan fileproc.WriteRequest, 1)
|
||||
wg.Go(func() {
|
||||
defer close(ch2)
|
||||
processor.Process(largeFile, ch2)
|
||||
})
|
||||
|
||||
results2 := make([]fileproc.WriteRequest, 0)
|
||||
for req := range ch2 {
|
||||
results2 = append(results2, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should get results because even large files are processed (just different strategy)
|
||||
t.Logf("Large file processing results: %d", len(results2))
|
||||
}
|
||||
|
||||
// TestProcessorContextCancellationDuringValidation tests context cancellation during file validation.
|
||||
func TestProcessorContextCancellationDuringValidation(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
content := testContent
|
||||
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
|
||||
// Create context that we'll cancel during processing
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
|
||||
// Let context expire
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
if err := processor.ProcessWithContext(ctx, testFile, ch); err != nil {
|
||||
t.Logf("ProcessWithContext error (may be expected): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
results := make([]fileproc.WriteRequest, 0)
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Should get no results due to context cancellation
|
||||
t.Logf("Results with canceled context: %d", len(results))
|
||||
}
|
||||
|
||||
// TestProcessorInMemoryProcessingEdgeCases tests edge cases in in-memory processing.
|
||||
func TestProcessorInMemoryProcessingEdgeCases(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Test with empty file
|
||||
emptyFile := filepath.Join(tmpDir, "empty.go")
|
||||
if err := os.WriteFile(emptyFile, []byte(""), 0o600); err != nil {
|
||||
t.Fatalf("Failed to create empty file: %v", err)
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
processor.Process(emptyFile, ch)
|
||||
})
|
||||
|
||||
results := make([]fileproc.WriteRequest, 0)
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Errorf("Expected 1 result for empty file, got %d", len(results))
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
result := results[0]
|
||||
if result.Path == "" {
|
||||
t.Error("Expected path in result for empty file")
|
||||
}
|
||||
// Empty file should still be processed
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessorStreamingEdgeCases tests edge cases in streaming processing.
|
||||
func TestProcessorStreamingEdgeCases(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a file larger than streaming threshold but test error conditions
|
||||
largeFile := filepath.Join(tmpDir, "large_stream.go")
|
||||
largeContent := strings.Repeat("// Large streaming file content line\n", 50000) // > 1MB
|
||||
if err := os.WriteFile(largeFile, []byte(largeContent), 0o600); err != nil {
|
||||
t.Fatalf("Failed to create large file: %v", err)
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
|
||||
// Test with context that gets canceled during streaming
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
|
||||
// Start processing
|
||||
// Error is expected due to cancellation
|
||||
if err := processor.ProcessWithContext(ctx, largeFile, ch); err != nil {
|
||||
// Log error for debugging, but don't fail test since cancellation is expected
|
||||
t.Logf("Expected error due to cancellation: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel context after a very short time
|
||||
go func() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
results := make([]fileproc.WriteRequest, 0)
|
||||
for req := range ch {
|
||||
results = append(results, req)
|
||||
|
||||
// If we get a streaming request, try to read from it with canceled context
|
||||
if req.IsStream && req.Reader != nil {
|
||||
buffer := make([]byte, 1024)
|
||||
_, err := req.Reader.Read(buffer)
|
||||
if err != nil && err != io.EOF {
|
||||
t.Logf("Expected error reading from canceled stream: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("Results with streaming context cancellation: %d", len(results))
|
||||
}
|
||||
|
||||
// Benchmarks for processor hot paths
|
||||
|
||||
// BenchmarkProcessFileInline benchmarks inline file processing for small files.
|
||||
func BenchmarkProcessFileInline(b *testing.B) {
|
||||
// Initialize config for file processing
|
||||
viper.Reset()
|
||||
config.LoadConfig()
|
||||
|
||||
// Create a small test file
|
||||
tmpFile, err := os.CreateTemp(b.TempDir(), "bench_inline_*.go")
|
||||
if err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
content := strings.Repeat("// Inline benchmark content\n", 100) // ~2.6KB
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToWriteContent, err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCloseFile, err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
fileproc.ProcessFile(tmpFile.Name(), ch, "")
|
||||
})
|
||||
for req := range ch {
|
||||
_ = req // Drain channel
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkProcessFileStreaming benchmarks streaming file processing for large files.
|
||||
func BenchmarkProcessFileStreaming(b *testing.B) {
|
||||
// Initialize config for file processing
|
||||
viper.Reset()
|
||||
config.LoadConfig()
|
||||
|
||||
// Create a large test file that triggers streaming
|
||||
tmpFile, err := os.CreateTemp(b.TempDir(), "bench_streaming_*.go")
|
||||
if err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
// Create content larger than streaming threshold (1MB)
|
||||
lineContent := "// Streaming benchmark content line that will be repeated\n"
|
||||
repeatCount := (1048576 / len(lineContent)) + 1000
|
||||
content := strings.Repeat(lineContent, repeatCount)
|
||||
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToWriteContent, err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCloseFile, err)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
fileproc.ProcessFile(tmpFile.Name(), ch, "")
|
||||
})
|
||||
for req := range ch {
|
||||
// If streaming, read some content to exercise the reader
|
||||
if req.IsStream && req.Reader != nil {
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
_, err := req.Reader.Read(buffer)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkProcessorWithContext benchmarks ProcessWithContext for a single file.
|
||||
func BenchmarkProcessorWithContext(b *testing.B) {
|
||||
tmpDir := b.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "bench_context.go")
|
||||
content := strings.Repeat("// Benchmark file content\n", 50)
|
||||
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
_ = processor.ProcessWithContext(ctx, testFile, ch)
|
||||
})
|
||||
for req := range ch {
|
||||
_ = req // Drain channel
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkProcessorWithMonitor benchmarks processing with resource monitoring.
|
||||
func BenchmarkProcessorWithMonitor(b *testing.B) {
|
||||
tmpDir := b.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "bench_monitor.go")
|
||||
content := strings.Repeat("// Benchmark file content with monitor\n", 50)
|
||||
if err := os.WriteFile(testFile, []byte(content), 0o600); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
|
||||
monitor := fileproc.NewResourceMonitor()
|
||||
defer monitor.Close()
|
||||
|
||||
processor := fileproc.NewFileProcessorWithMonitor(tmpDir, monitor)
|
||||
ctx := context.Background()
|
||||
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
_ = processor.ProcessWithContext(ctx, testFile, ch)
|
||||
})
|
||||
for req := range ch {
|
||||
_ = req // Drain channel
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkProcessorConcurrent benchmarks concurrent file processing.
|
||||
func BenchmarkProcessorConcurrent(b *testing.B) {
|
||||
tmpDir := b.TempDir()
|
||||
|
||||
// Create multiple test files
|
||||
testFiles := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
testFiles[i] = filepath.Join(tmpDir, fmt.Sprintf("bench_concurrent_%d.go", i))
|
||||
content := strings.Repeat(fmt.Sprintf("// Concurrent file %d content\n", i), 50)
|
||||
if err := os.WriteFile(testFiles[i], []byte(content), 0o600); err != nil {
|
||||
b.Fatalf(shared.TestMsgFailedToCreateTestFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
processor := fileproc.NewFileProcessor(tmpDir)
|
||||
ctx := context.Background()
|
||||
fileCount := len(testFiles)
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
testFile := testFiles[i%fileCount]
|
||||
ch := make(chan fileproc.WriteRequest, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
defer close(ch)
|
||||
_ = processor.ProcessWithContext(ctx, testFile, ch)
|
||||
})
|
||||
for req := range ch {
|
||||
_ = req // Drain channel
|
||||
}
|
||||
wg.Wait()
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
114
fileproc/registry.go
Normal file
114
fileproc/registry.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package fileproc provides file processing utilities.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
const minExtensionLength = 2
|
||||
|
||||
var (
|
||||
registry *FileTypeRegistry
|
||||
registryOnce sync.Once
|
||||
)
|
||||
|
||||
// FileTypeRegistry manages file type detection and classification.
|
||||
type FileTypeRegistry struct {
|
||||
imageExts map[string]bool
|
||||
binaryExts map[string]bool
|
||||
languageMap map[string]string
|
||||
|
||||
// Cache for frequent lookups to avoid repeated string operations
|
||||
extCache map[string]string // filename -> normalized extension
|
||||
resultCache map[string]FileTypeResult // extension -> cached result
|
||||
cacheMutex sync.RWMutex
|
||||
maxCacheSize int
|
||||
|
||||
// Performance statistics
|
||||
stats RegistryStats
|
||||
}
|
||||
|
||||
// RegistryStats tracks performance metrics for the registry.
|
||||
type RegistryStats struct {
|
||||
TotalLookups uint64
|
||||
CacheHits uint64
|
||||
CacheMisses uint64
|
||||
CacheEvictions uint64
|
||||
}
|
||||
|
||||
// FileTypeResult represents cached file type detection results.
|
||||
type FileTypeResult struct {
|
||||
IsImage bool
|
||||
IsBinary bool
|
||||
Language string
|
||||
Extension string
|
||||
}
|
||||
|
||||
// initRegistry initializes the default file type registry with common extensions.
|
||||
func initRegistry() *FileTypeRegistry {
|
||||
return &FileTypeRegistry{
|
||||
imageExts: getImageExtensions(),
|
||||
binaryExts: getBinaryExtensions(),
|
||||
languageMap: getLanguageMap(),
|
||||
extCache: make(map[string]string, shared.FileTypeRegistryMaxCacheSize),
|
||||
resultCache: make(map[string]FileTypeResult, shared.FileTypeRegistryMaxCacheSize),
|
||||
maxCacheSize: shared.FileTypeRegistryMaxCacheSize,
|
||||
}
|
||||
}
|
||||
|
||||
// getRegistry returns the singleton file type registry, creating it if necessary.
|
||||
func getRegistry() *FileTypeRegistry {
|
||||
registryOnce.Do(func() {
|
||||
registry = initRegistry()
|
||||
})
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// DefaultRegistry returns the default file type registry.
|
||||
func DefaultRegistry() *FileTypeRegistry {
|
||||
return getRegistry()
|
||||
}
|
||||
|
||||
// Stats returns a copy of the current registry statistics.
|
||||
func (r *FileTypeRegistry) Stats() RegistryStats {
|
||||
r.cacheMutex.RLock()
|
||||
defer r.cacheMutex.RUnlock()
|
||||
|
||||
return r.stats
|
||||
}
|
||||
|
||||
// CacheInfo returns current cache size information.
|
||||
func (r *FileTypeRegistry) CacheInfo() (extCacheSize, resultCacheSize, maxCacheSize int) {
|
||||
r.cacheMutex.RLock()
|
||||
defer r.cacheMutex.RUnlock()
|
||||
|
||||
return len(r.extCache), len(r.resultCache), r.maxCacheSize
|
||||
}
|
||||
|
||||
// ResetRegistryForTesting resets the registry to its initial state.
|
||||
// This function should only be used in tests.
|
||||
func ResetRegistryForTesting() {
|
||||
registryOnce = sync.Once{}
|
||||
registry = nil
|
||||
}
|
||||
|
||||
// normalizeExtension extracts and normalizes the file extension.
|
||||
func normalizeExtension(filename string) string {
|
||||
return strings.ToLower(filepath.Ext(filename))
|
||||
}
|
||||
|
||||
// isSpecialFile checks if the filename matches special cases like .DS_Store.
|
||||
func isSpecialFile(filename string, extensions map[string]bool) bool {
|
||||
if filepath.Ext(filename) == "" {
|
||||
basename := strings.ToLower(filepath.Base(filename))
|
||||
|
||||
return extensions[basename]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
68
fileproc/resource_monitor_concurrency.go
Normal file
68
fileproc/resource_monitor_concurrency.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AcquireReadSlot attempts to acquire a slot for concurrent file reading.
|
||||
func (rm *ResourceMonitor) AcquireReadSlot(ctx context.Context) error {
|
||||
if !rm.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait for available read slot
|
||||
for {
|
||||
currentReads := atomic.LoadInt64(&rm.concurrentReads)
|
||||
if currentReads < int64(rm.maxConcurrentReads) {
|
||||
if atomic.CompareAndSwapInt64(&rm.concurrentReads, currentReads, currentReads+1) {
|
||||
break
|
||||
}
|
||||
// CAS failed, retry
|
||||
continue
|
||||
}
|
||||
|
||||
// Wait and retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context canceled while waiting for read slot: %w", ctx.Err())
|
||||
case <-time.After(time.Millisecond):
|
||||
// Continue loop
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseReadSlot releases a concurrent reading slot.
|
||||
func (rm *ResourceMonitor) ReleaseReadSlot() {
|
||||
if rm.enabled {
|
||||
atomic.AddInt64(&rm.concurrentReads, -1)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateFileProcessingContext creates a context with file processing timeout.
|
||||
func (rm *ResourceMonitor) CreateFileProcessingContext(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
if !rm.enabled || rm.fileProcessingTimeout <= 0 {
|
||||
// No-op cancel function - monitoring disabled or no timeout configured
|
||||
return parent, func() {}
|
||||
}
|
||||
|
||||
return context.WithTimeout(parent, rm.fileProcessingTimeout)
|
||||
}
|
||||
|
||||
// CreateOverallProcessingContext creates a context with overall processing timeout.
|
||||
func (rm *ResourceMonitor) CreateOverallProcessingContext(parent context.Context) (
|
||||
context.Context,
|
||||
context.CancelFunc,
|
||||
) {
|
||||
if !rm.enabled || rm.overallTimeout <= 0 {
|
||||
// No-op cancel function - monitoring disabled or no timeout configured
|
||||
return parent, func() {}
|
||||
}
|
||||
|
||||
return context.WithTimeout(parent, rm.overallTimeout)
|
||||
}
|
||||
95
fileproc/resource_monitor_concurrency_test.go
Normal file
95
fileproc/resource_monitor_concurrency_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestResourceMonitorConcurrentReadsLimit(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set a low concurrent reads limit for testing
|
||||
viper.Set("resourceLimits.enabled", true)
|
||||
viper.Set("resourceLimits.maxConcurrentReads", 2)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// First read slot should succeed
|
||||
err := rm.AcquireReadSlot(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for first read slot, got %v", err)
|
||||
}
|
||||
|
||||
// Second read slot should succeed
|
||||
err = rm.AcquireReadSlot(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for second read slot, got %v", err)
|
||||
}
|
||||
|
||||
// Third read slot should time out (context deadline exceeded)
|
||||
err = rm.AcquireReadSlot(ctx)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error for third read slot, got nil")
|
||||
}
|
||||
|
||||
// Release one slot and try again
|
||||
rm.ReleaseReadSlot()
|
||||
|
||||
// Create new context for the next attempt
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel2()
|
||||
|
||||
err = rm.AcquireReadSlot(ctx2)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error after releasing a slot, got %v", err)
|
||||
}
|
||||
|
||||
// Clean up remaining slots
|
||||
rm.ReleaseReadSlot()
|
||||
rm.ReleaseReadSlot()
|
||||
}
|
||||
|
||||
func TestResourceMonitorTimeoutContexts(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set short timeouts for testing
|
||||
viper.Set("resourceLimits.enabled", true)
|
||||
viper.Set("resourceLimits.fileProcessingTimeoutSec", 1) // 1 second
|
||||
viper.Set("resourceLimits.overallTimeoutSec", 2) // 2 seconds
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
parentCtx := context.Background()
|
||||
|
||||
// Test file processing context
|
||||
fileCtx, fileCancel := rm.CreateFileProcessingContext(parentCtx)
|
||||
defer fileCancel()
|
||||
|
||||
deadline, ok := fileCtx.Deadline()
|
||||
if !ok {
|
||||
t.Error("Expected file processing context to have a deadline")
|
||||
} else if time.Until(deadline) > time.Second+100*time.Millisecond {
|
||||
t.Error("File processing timeout appears to be too long")
|
||||
}
|
||||
|
||||
// Test overall processing context
|
||||
overallCtx, overallCancel := rm.CreateOverallProcessingContext(parentCtx)
|
||||
defer overallCancel()
|
||||
|
||||
deadline, ok = overallCtx.Deadline()
|
||||
if !ok {
|
||||
t.Error("Expected overall processing context to have a deadline")
|
||||
} else if time.Until(deadline) > 2*time.Second+100*time.Millisecond {
|
||||
t.Error("Overall processing timeout appears to be too long")
|
||||
}
|
||||
}
|
||||
83
fileproc/resource_monitor_integration_test.go
Normal file
83
fileproc/resource_monitor_integration_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestResourceMonitorIntegration(t *testing.T) {
|
||||
// Create temporary test directory
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
testFiles := []string{"test1.txt", "test2.txt", "test3.txt"}
|
||||
for _, filename := range testFiles {
|
||||
testutil.CreateTestFile(t, tempDir, filename, []byte("test content"))
|
||||
}
|
||||
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Configure resource limits
|
||||
viper.Set("resourceLimits.enabled", true)
|
||||
viper.Set("resourceLimits.maxFiles", 5)
|
||||
viper.Set("resourceLimits.maxTotalSize", 1024*1024) // 1MB
|
||||
viper.Set("resourceLimits.fileProcessingTimeoutSec", 10)
|
||||
viper.Set("resourceLimits.maxConcurrentReads", 3)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test file processing workflow
|
||||
for _, filename := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to stat test file %s: %v", filePath, err)
|
||||
}
|
||||
|
||||
// Validate file can be processed
|
||||
err = rm.ValidateFileProcessing(filePath, fileInfo.Size())
|
||||
if err != nil {
|
||||
t.Errorf("Failed to validate file %s: %v", filePath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Acquire read slot
|
||||
err = rm.AcquireReadSlot(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to acquire read slot for %s: %v", filePath, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Check memory limits
|
||||
err = rm.CheckHardMemoryLimit()
|
||||
if err != nil {
|
||||
t.Errorf("Memory limit check failed for %s: %v", filePath, err)
|
||||
}
|
||||
|
||||
// Record processing
|
||||
rm.RecordFileProcessed(fileInfo.Size())
|
||||
|
||||
// Release read slot
|
||||
rm.ReleaseReadSlot()
|
||||
}
|
||||
|
||||
// Verify final metrics
|
||||
metrics := rm.Metrics()
|
||||
if metrics.FilesProcessed != int64(len(testFiles)) {
|
||||
t.Errorf("Expected %d files processed, got %d", len(testFiles), metrics.FilesProcessed)
|
||||
}
|
||||
|
||||
// Test resource limit logging
|
||||
rm.LogResourceInfo()
|
||||
}
|
||||
83
fileproc/resource_monitor_metrics.go
Normal file
83
fileproc/resource_monitor_metrics.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// RecordFileProcessed records that a file has been successfully processed.
|
||||
func (rm *ResourceMonitor) RecordFileProcessed(fileSize int64) {
|
||||
if rm.enabled {
|
||||
atomic.AddInt64(&rm.filesProcessed, 1)
|
||||
atomic.AddInt64(&rm.totalSizeProcessed, fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics returns current resource usage metrics.
|
||||
func (rm *ResourceMonitor) Metrics() ResourceMetrics {
|
||||
if !rm.enableResourceMon {
|
||||
return ResourceMetrics{}
|
||||
}
|
||||
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
|
||||
filesProcessed := atomic.LoadInt64(&rm.filesProcessed)
|
||||
totalSize := atomic.LoadInt64(&rm.totalSizeProcessed)
|
||||
duration := time.Since(rm.startTime)
|
||||
|
||||
avgFileSize := float64(0)
|
||||
if filesProcessed > 0 {
|
||||
avgFileSize = float64(totalSize) / float64(filesProcessed)
|
||||
}
|
||||
|
||||
processingRate := float64(0)
|
||||
if duration.Seconds() > 0 {
|
||||
processingRate = float64(filesProcessed) / duration.Seconds()
|
||||
}
|
||||
|
||||
// Collect violations
|
||||
violations := make([]string, 0, len(rm.violationLogged))
|
||||
for violation := range rm.violationLogged {
|
||||
violations = append(violations, violation)
|
||||
}
|
||||
|
||||
return ResourceMetrics{
|
||||
FilesProcessed: filesProcessed,
|
||||
TotalSizeProcessed: totalSize,
|
||||
ConcurrentReads: atomic.LoadInt64(&rm.concurrentReads),
|
||||
MaxConcurrentReads: int64(rm.maxConcurrentReads),
|
||||
ProcessingDuration: duration,
|
||||
AverageFileSize: avgFileSize,
|
||||
ProcessingRate: processingRate,
|
||||
MemoryUsageMB: shared.BytesToMB(m.Alloc),
|
||||
MaxMemoryUsageMB: int64(rm.hardMemoryLimitMB),
|
||||
ViolationsDetected: violations,
|
||||
DegradationActive: rm.degradationActive,
|
||||
EmergencyStopActive: rm.emergencyStopRequested,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// LogResourceInfo logs current resource limit configuration.
|
||||
func (rm *ResourceMonitor) LogResourceInfo() {
|
||||
logger := shared.GetLogger()
|
||||
if rm.enabled {
|
||||
logger.Infof("Resource limits enabled: maxFiles=%d, maxTotalSize=%dMB, fileTimeout=%ds, overallTimeout=%ds",
|
||||
rm.maxFiles, rm.maxTotalSize/int64(shared.BytesPerMB), int(rm.fileProcessingTimeout.Seconds()),
|
||||
int(rm.overallTimeout.Seconds()))
|
||||
logger.Infof("Resource limits: maxConcurrentReads=%d, rateLimitFPS=%d, hardMemoryMB=%d",
|
||||
rm.maxConcurrentReads, rm.rateLimitFilesPerSec, rm.hardMemoryLimitMB)
|
||||
logger.Infof("Resource features: gracefulDegradation=%v, monitoring=%v",
|
||||
rm.enableGracefulDegr, rm.enableResourceMon)
|
||||
} else {
|
||||
logger.Info("Resource limits disabled")
|
||||
}
|
||||
}
|
||||
49
fileproc/resource_monitor_metrics_test.go
Normal file
49
fileproc/resource_monitor_metrics_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestResourceMonitorMetrics(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
viper.Set("resourceLimits.enabled", true)
|
||||
viper.Set("resourceLimits.enableResourceMonitoring", true)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Process some files to generate metrics
|
||||
rm.RecordFileProcessed(1000)
|
||||
rm.RecordFileProcessed(2000)
|
||||
rm.RecordFileProcessed(500)
|
||||
|
||||
metrics := rm.Metrics()
|
||||
|
||||
// Verify metrics
|
||||
if metrics.FilesProcessed != 3 {
|
||||
t.Errorf("Expected 3 files processed, got %d", metrics.FilesProcessed)
|
||||
}
|
||||
|
||||
if metrics.TotalSizeProcessed != 3500 {
|
||||
t.Errorf("Expected total size 3500, got %d", metrics.TotalSizeProcessed)
|
||||
}
|
||||
|
||||
expectedAvgSize := float64(3500) / float64(3)
|
||||
if metrics.AverageFileSize != expectedAvgSize {
|
||||
t.Errorf("Expected average file size %.2f, got %.2f", expectedAvgSize, metrics.AverageFileSize)
|
||||
}
|
||||
|
||||
if metrics.ProcessingRate <= 0 {
|
||||
t.Error("Expected positive processing rate")
|
||||
}
|
||||
|
||||
if !metrics.LastUpdated.After(time.Now().Add(-time.Second)) {
|
||||
t.Error("Expected recent LastUpdated timestamp")
|
||||
}
|
||||
}
|
||||
45
fileproc/resource_monitor_rate_limiting.go
Normal file
45
fileproc/resource_monitor_rate_limiting.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// WaitForRateLimit waits for rate limiting if enabled.
|
||||
func (rm *ResourceMonitor) WaitForRateLimit(ctx context.Context) error {
|
||||
if !rm.enabled || rm.rateLimitFilesPerSec <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("context canceled while waiting for rate limit: %w", ctx.Err())
|
||||
case <-rm.rateLimitChan:
|
||||
return nil
|
||||
case <-time.After(time.Second): // Fallback timeout
|
||||
logger := shared.GetLogger()
|
||||
logger.Warn("Rate limiting timeout exceeded, continuing without rate limit")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimiterRefill refills the rate limiting channel periodically.
|
||||
func (rm *ResourceMonitor) rateLimiterRefill() {
|
||||
for {
|
||||
select {
|
||||
case <-rm.done:
|
||||
return
|
||||
case <-rm.rateLimiter.C:
|
||||
select {
|
||||
case rm.rateLimitChan <- struct{}{}:
|
||||
default:
|
||||
// Channel is full, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
fileproc/resource_monitor_rate_limiting_test.go
Normal file
40
fileproc/resource_monitor_rate_limiting_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestResourceMonitorRateLimiting(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Enable rate limiting with a low rate for testing
|
||||
viper.Set("resourceLimits.enabled", true)
|
||||
viper.Set("resourceLimits.rateLimitFilesPerSec", 5) // 5 files per second
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// First few requests should succeed quickly
|
||||
start := time.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
err := rm.WaitForRateLimit(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for rate limit wait %d, got %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have taken some time due to rate limiting
|
||||
duration := time.Since(start)
|
||||
if duration < 200*time.Millisecond {
|
||||
t.Logf("Rate limiting may not be working as expected, took only %v", duration)
|
||||
}
|
||||
}
|
||||
40
fileproc/resource_monitor_state.go
Normal file
40
fileproc/resource_monitor_state.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
// IsEmergencyStopActive returns whether emergency stop is active.
|
||||
func (rm *ResourceMonitor) IsEmergencyStopActive() bool {
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
return rm.emergencyStopRequested
|
||||
}
|
||||
|
||||
// IsDegradationActive returns whether degradation mode is active.
|
||||
func (rm *ResourceMonitor) IsDegradationActive() bool {
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
return rm.degradationActive
|
||||
}
|
||||
|
||||
// Close cleans up the resource monitor.
|
||||
func (rm *ResourceMonitor) Close() {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
// Prevent multiple closes
|
||||
if rm.closed {
|
||||
return
|
||||
}
|
||||
rm.closed = true
|
||||
|
||||
// Signal goroutines to stop
|
||||
if rm.done != nil {
|
||||
close(rm.done)
|
||||
}
|
||||
|
||||
// Stop the ticker
|
||||
if rm.rateLimiter != nil {
|
||||
rm.rateLimiter.Stop()
|
||||
}
|
||||
}
|
||||
114
fileproc/resource_monitor_types.go
Normal file
114
fileproc/resource_monitor_types.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// ResourceMonitor monitors resource usage and enforces limits to prevent DoS attacks.
|
||||
type ResourceMonitor struct {
|
||||
enabled bool
|
||||
maxFiles int
|
||||
maxTotalSize int64
|
||||
fileProcessingTimeout time.Duration
|
||||
overallTimeout time.Duration
|
||||
maxConcurrentReads int
|
||||
rateLimitFilesPerSec int
|
||||
hardMemoryLimitMB int
|
||||
enableGracefulDegr bool
|
||||
enableResourceMon bool
|
||||
|
||||
// Current state tracking
|
||||
filesProcessed int64
|
||||
totalSizeProcessed int64
|
||||
concurrentReads int64
|
||||
startTime time.Time
|
||||
lastRateLimitCheck time.Time
|
||||
hardMemoryLimitBytes int64
|
||||
|
||||
// Rate limiting
|
||||
rateLimiter *time.Ticker
|
||||
rateLimitChan chan struct{}
|
||||
done chan struct{} // Signal to stop goroutines
|
||||
|
||||
// Synchronization
|
||||
mu sync.RWMutex
|
||||
violationLogged map[string]bool
|
||||
degradationActive bool
|
||||
emergencyStopRequested bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
// ResourceMetrics holds comprehensive resource usage metrics.
|
||||
type ResourceMetrics struct {
|
||||
FilesProcessed int64 `json:"files_processed"`
|
||||
TotalSizeProcessed int64 `json:"total_size_processed"`
|
||||
ConcurrentReads int64 `json:"concurrent_reads"`
|
||||
MaxConcurrentReads int64 `json:"max_concurrent_reads"`
|
||||
ProcessingDuration time.Duration `json:"processing_duration"`
|
||||
AverageFileSize float64 `json:"average_file_size"`
|
||||
ProcessingRate float64 `json:"processing_rate_files_per_sec"`
|
||||
MemoryUsageMB int64 `json:"memory_usage_mb"`
|
||||
MaxMemoryUsageMB int64 `json:"max_memory_usage_mb"`
|
||||
ViolationsDetected []string `json:"violations_detected"`
|
||||
DegradationActive bool `json:"degradation_active"`
|
||||
EmergencyStopActive bool `json:"emergency_stop_active"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// ResourceViolation represents a detected resource limit violation.
|
||||
type ResourceViolation struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Current any `json:"current"`
|
||||
Limit any `json:"limit"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Context map[string]any `json:"context"`
|
||||
}
|
||||
|
||||
// NewResourceMonitor creates a new resource monitor with configuration.
|
||||
func NewResourceMonitor() *ResourceMonitor {
|
||||
rm := &ResourceMonitor{
|
||||
enabled: config.ResourceLimitsEnabled(),
|
||||
maxFiles: config.MaxFiles(),
|
||||
maxTotalSize: config.MaxTotalSize(),
|
||||
fileProcessingTimeout: time.Duration(config.FileProcessingTimeoutSec()) * time.Second,
|
||||
overallTimeout: time.Duration(config.OverallTimeoutSec()) * time.Second,
|
||||
maxConcurrentReads: config.MaxConcurrentReads(),
|
||||
rateLimitFilesPerSec: config.RateLimitFilesPerSec(),
|
||||
hardMemoryLimitMB: config.HardMemoryLimitMB(),
|
||||
enableGracefulDegr: config.EnableGracefulDegradation(),
|
||||
enableResourceMon: config.EnableResourceMonitoring(),
|
||||
startTime: time.Now(),
|
||||
lastRateLimitCheck: time.Now(),
|
||||
violationLogged: make(map[string]bool),
|
||||
hardMemoryLimitBytes: int64(config.HardMemoryLimitMB()) * int64(shared.BytesPerMB),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Initialize rate limiter if rate limiting is enabled
|
||||
if rm.enabled && rm.rateLimitFilesPerSec > 0 {
|
||||
interval := time.Second / time.Duration(rm.rateLimitFilesPerSec)
|
||||
rm.rateLimiter = time.NewTicker(interval)
|
||||
rm.rateLimitChan = make(chan struct{}, rm.rateLimitFilesPerSec)
|
||||
|
||||
// Pre-fill the rate limit channel
|
||||
for i := 0; i < rm.rateLimitFilesPerSec; i++ {
|
||||
select {
|
||||
case rm.rateLimitChan <- struct{}{}:
|
||||
default:
|
||||
goto rateLimitFull
|
||||
}
|
||||
}
|
||||
rateLimitFull:
|
||||
|
||||
// Start rate limiter refill goroutine
|
||||
go rm.rateLimiterRefill()
|
||||
}
|
||||
|
||||
return rm
|
||||
}
|
||||
148
fileproc/resource_monitor_types_test.go
Normal file
148
fileproc/resource_monitor_types_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestResourceMonitorNewResourceMonitor(t *testing.T) {
|
||||
// Reset viper for clean test state
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
if rm == nil {
|
||||
t.Fatal("NewResourceMonitor() returned nil")
|
||||
}
|
||||
|
||||
// Test default values are set correctly
|
||||
if !rm.enabled {
|
||||
t.Error("Expected resource monitor to be enabled by default")
|
||||
}
|
||||
|
||||
if rm.maxFiles != shared.ConfigMaxFilesDefault {
|
||||
t.Errorf("Expected maxFiles to be %d, got %d", shared.ConfigMaxFilesDefault, rm.maxFiles)
|
||||
}
|
||||
|
||||
if rm.maxTotalSize != shared.ConfigMaxTotalSizeDefault {
|
||||
t.Errorf("Expected maxTotalSize to be %d, got %d", shared.ConfigMaxTotalSizeDefault, rm.maxTotalSize)
|
||||
}
|
||||
|
||||
if rm.fileProcessingTimeout != time.Duration(shared.ConfigFileProcessingTimeoutSecDefault)*time.Second {
|
||||
t.Errorf("Expected fileProcessingTimeout to be %v, got %v",
|
||||
time.Duration(shared.ConfigFileProcessingTimeoutSecDefault)*time.Second, rm.fileProcessingTimeout)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
rm.Close()
|
||||
}
|
||||
|
||||
func TestResourceMonitorDisabledResourceLimits(t *testing.T) {
|
||||
// Reset viper for clean test state
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set resource limits disabled
|
||||
viper.Set("resourceLimits.enabled", false)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Test that validation passes when disabled
|
||||
err := rm.ValidateFileProcessing("/tmp/test.txt", 1000)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when resource limits disabled, got %v", err)
|
||||
}
|
||||
|
||||
// Test that read slot acquisition works when disabled
|
||||
ctx := context.Background()
|
||||
err = rm.AcquireReadSlot(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when acquiring read slot with disabled limits, got %v", err)
|
||||
}
|
||||
rm.ReleaseReadSlot()
|
||||
|
||||
// Test that rate limiting is bypassed when disabled
|
||||
err = rm.WaitForRateLimit(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error when rate limiting disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceMonitorStateQueries tests state query functions.
|
||||
func TestResourceMonitorStateQueries(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Test IsEmergencyStopActive - should be false initially
|
||||
if rm.IsEmergencyStopActive() {
|
||||
t.Error("Expected emergency stop to be inactive initially")
|
||||
}
|
||||
|
||||
// Test IsDegradationActive - should be false initially
|
||||
if rm.IsDegradationActive() {
|
||||
t.Error("Expected degradation mode to be inactive initially")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceMonitorIsEmergencyStopActive tests the IsEmergencyStopActive method.
|
||||
func TestResourceMonitorIsEmergencyStopActive(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Test initial state
|
||||
active := rm.IsEmergencyStopActive()
|
||||
if active {
|
||||
t.Error("Expected emergency stop to be inactive initially")
|
||||
}
|
||||
|
||||
// The method should return a consistent value on multiple calls
|
||||
for i := 0; i < 5; i++ {
|
||||
if rm.IsEmergencyStopActive() != active {
|
||||
t.Error("IsEmergencyStopActive should return consistent values")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceMonitorIsDegradationActive tests the IsDegradationActive method.
|
||||
func TestResourceMonitorIsDegradationActive(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Test initial state
|
||||
active := rm.IsDegradationActive()
|
||||
if active {
|
||||
t.Error("Expected degradation mode to be inactive initially")
|
||||
}
|
||||
|
||||
// The method should return a consistent value on multiple calls
|
||||
for i := 0; i < 5; i++ {
|
||||
if rm.IsDegradationActive() != active {
|
||||
t.Error("IsDegradationActive should return consistent values")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceMonitorClose tests the Close method.
|
||||
func TestResourceMonitorClose(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
|
||||
// Close should not panic
|
||||
rm.Close()
|
||||
|
||||
// Multiple closes should be safe
|
||||
rm.Close()
|
||||
rm.Close()
|
||||
}
|
||||
179
fileproc/resource_monitor_validation.go
Normal file
179
fileproc/resource_monitor_validation.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// ValidateFileProcessing checks if a file can be processed based on resource limits.
|
||||
func (rm *ResourceMonitor) ValidateFileProcessing(filePath string, fileSize int64) error {
|
||||
if !rm.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
rm.mu.RLock()
|
||||
defer rm.mu.RUnlock()
|
||||
|
||||
// Check if emergency stop is active
|
||||
if rm.emergencyStopRequested {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitMemory,
|
||||
"processing stopped due to emergency memory condition",
|
||||
filePath,
|
||||
map[string]any{
|
||||
"emergency_stop_active": true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Check file count limit
|
||||
currentFiles := atomic.LoadInt64(&rm.filesProcessed)
|
||||
if int(currentFiles) >= rm.maxFiles {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitFiles,
|
||||
"maximum file count limit exceeded",
|
||||
filePath,
|
||||
map[string]any{
|
||||
"current_files": currentFiles,
|
||||
"max_files": rm.maxFiles,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Check total size limit
|
||||
currentTotalSize := atomic.LoadInt64(&rm.totalSizeProcessed)
|
||||
if currentTotalSize+fileSize > rm.maxTotalSize {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTotalSize,
|
||||
"maximum total size limit would be exceeded",
|
||||
filePath,
|
||||
map[string]any{
|
||||
"current_total_size": currentTotalSize,
|
||||
"file_size": fileSize,
|
||||
"max_total_size": rm.maxTotalSize,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Check overall timeout
|
||||
if time.Since(rm.startTime) > rm.overallTimeout {
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitTimeout,
|
||||
"overall processing timeout exceeded",
|
||||
filePath,
|
||||
map[string]any{
|
||||
"processing_duration": time.Since(rm.startTime),
|
||||
"overall_timeout": rm.overallTimeout,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckHardMemoryLimit checks if hard memory limit is exceeded and takes action.
|
||||
func (rm *ResourceMonitor) CheckHardMemoryLimit() error {
|
||||
if !rm.enabled || rm.hardMemoryLimitMB <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
currentMemory := shared.SafeUint64ToInt64WithDefault(m.Alloc, 0)
|
||||
|
||||
if currentMemory <= rm.hardMemoryLimitBytes {
|
||||
return nil
|
||||
}
|
||||
|
||||
return rm.handleMemoryLimitExceeded(currentMemory)
|
||||
}
|
||||
|
||||
// handleMemoryLimitExceeded handles the case when hard memory limit is exceeded.
|
||||
func (rm *ResourceMonitor) handleMemoryLimitExceeded(currentMemory int64) error {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
rm.logMemoryViolation(currentMemory)
|
||||
|
||||
if !rm.enableGracefulDegr {
|
||||
return rm.createHardMemoryLimitError(currentMemory, false)
|
||||
}
|
||||
|
||||
return rm.tryGracefulRecovery(currentMemory)
|
||||
}
|
||||
|
||||
// logMemoryViolation logs memory limit violation if not already logged.
|
||||
func (rm *ResourceMonitor) logMemoryViolation(currentMemory int64) {
|
||||
violationKey := "hard_memory_limit"
|
||||
|
||||
// Ensure map is initialized
|
||||
if rm.violationLogged == nil {
|
||||
rm.violationLogged = make(map[string]bool)
|
||||
}
|
||||
|
||||
if rm.violationLogged[violationKey] {
|
||||
return
|
||||
}
|
||||
|
||||
logger := shared.GetLogger()
|
||||
logger.Errorf("Hard memory limit exceeded: %dMB > %dMB",
|
||||
currentMemory/int64(shared.BytesPerMB), rm.hardMemoryLimitMB)
|
||||
rm.violationLogged[violationKey] = true
|
||||
}
|
||||
|
||||
// tryGracefulRecovery attempts graceful recovery by forcing GC.
|
||||
func (rm *ResourceMonitor) tryGracefulRecovery(_ int64) error {
|
||||
// Force garbage collection
|
||||
runtime.GC()
|
||||
|
||||
// Check again after GC
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
newMemory := shared.SafeUint64ToInt64WithDefault(m.Alloc, 0)
|
||||
|
||||
if newMemory > rm.hardMemoryLimitBytes {
|
||||
// Still over limit, activate emergency stop
|
||||
rm.emergencyStopRequested = true
|
||||
|
||||
return rm.createHardMemoryLimitError(newMemory, true)
|
||||
}
|
||||
|
||||
// Memory freed by GC, continue with degradation
|
||||
rm.degradationActive = true
|
||||
logger := shared.GetLogger()
|
||||
logger.Info("Memory freed by garbage collection, continuing with degradation mode")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createHardMemoryLimitError creates a structured error for memory limit exceeded.
|
||||
func (rm *ResourceMonitor) createHardMemoryLimitError(currentMemory int64, emergencyStop bool) error {
|
||||
message := "hard memory limit exceeded"
|
||||
if emergencyStop {
|
||||
message = "hard memory limit exceeded, emergency stop activated"
|
||||
}
|
||||
|
||||
context := map[string]any{
|
||||
"current_memory_mb": currentMemory / int64(shared.BytesPerMB),
|
||||
"limit_mb": rm.hardMemoryLimitMB,
|
||||
}
|
||||
if emergencyStop {
|
||||
context["emergency_stop"] = true
|
||||
}
|
||||
|
||||
return shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeResourceLimitMemory,
|
||||
message,
|
||||
"",
|
||||
context,
|
||||
)
|
||||
}
|
||||
204
fileproc/resource_monitor_validation_test.go
Normal file
204
fileproc/resource_monitor_validation_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
// assertStructuredError verifies that an error is a StructuredError with the expected code.
|
||||
func assertStructuredError(t *testing.T, err error, expectedCode string) {
|
||||
t.Helper()
|
||||
structErr := &shared.StructuredError{}
|
||||
ok := errors.As(err, &structErr)
|
||||
if !ok {
|
||||
t.Errorf("Expected StructuredError, got %T", err)
|
||||
} else if structErr.Code != expectedCode {
|
||||
t.Errorf("Expected error code %s, got %s", expectedCode, structErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// validateMemoryLimitError validates that an error is a proper memory limit StructuredError.
|
||||
func validateMemoryLimitError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
|
||||
structErr := &shared.StructuredError{}
|
||||
if errors.As(err, &structErr) {
|
||||
if structErr.Code != shared.CodeResourceLimitMemory {
|
||||
t.Errorf("Expected memory limit error code, got %s", structErr.Code)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected StructuredError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceMonitorFileCountLimit(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set a very low file count limit for testing
|
||||
viper.Set(shared.TestCfgResourceLimitsEnabled, true)
|
||||
viper.Set("resourceLimits.maxFiles", 2)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// First file should pass
|
||||
err := rm.ValidateFileProcessing("/tmp/file1.txt", 100)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for first file, got %v", err)
|
||||
}
|
||||
rm.RecordFileProcessed(100)
|
||||
|
||||
// Second file should pass
|
||||
err = rm.ValidateFileProcessing("/tmp/file2.txt", 100)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for second file, got %v", err)
|
||||
}
|
||||
rm.RecordFileProcessed(100)
|
||||
|
||||
// Third file should fail
|
||||
err = rm.ValidateFileProcessing("/tmp/file3.txt", 100)
|
||||
if err == nil {
|
||||
t.Error("Expected error for third file (exceeds limit), got nil")
|
||||
}
|
||||
|
||||
// Verify it's the correct error type
|
||||
assertStructuredError(t, err, shared.CodeResourceLimitFiles)
|
||||
}
|
||||
|
||||
func TestResourceMonitorTotalSizeLimit(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set a low total size limit for testing (1KB)
|
||||
viper.Set(shared.TestCfgResourceLimitsEnabled, true)
|
||||
viper.Set("resourceLimits.maxTotalSize", 1024)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// First small file should pass
|
||||
err := rm.ValidateFileProcessing("/tmp/small.txt", 500)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for small file, got %v", err)
|
||||
}
|
||||
rm.RecordFileProcessed(500)
|
||||
|
||||
// Second small file should pass
|
||||
err = rm.ValidateFileProcessing("/tmp/small2.txt", 400)
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for second small file, got %v", err)
|
||||
}
|
||||
rm.RecordFileProcessed(400)
|
||||
|
||||
// Large file that would exceed limit should fail
|
||||
err = rm.ValidateFileProcessing("/tmp/large.txt", 200)
|
||||
if err == nil {
|
||||
t.Error("Expected error for file that would exceed size limit, got nil")
|
||||
}
|
||||
|
||||
// Verify it's the correct error type
|
||||
assertStructuredError(t, err, shared.CodeResourceLimitTotalSize)
|
||||
}
|
||||
|
||||
// TestResourceMonitor_MemoryLimitExceeded tests memory limit violation scenarios.
|
||||
func TestResourceMonitorMemoryLimitExceeded(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set very low memory limit to try to force violations
|
||||
viper.Set(shared.TestCfgResourceLimitsEnabled, true)
|
||||
viper.Set("resourceLimits.hardMemoryLimitMB", 0.001) // 1KB - extremely low
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Allocate large buffer to increase memory usage before check
|
||||
largeBuffer := make([]byte, 10*1024*1024) // 10MB allocation
|
||||
_ = largeBuffer[0] // Use the buffer to prevent optimization
|
||||
|
||||
// Check hard memory limit - might trigger if actual memory is high enough
|
||||
err := rm.CheckHardMemoryLimit()
|
||||
|
||||
// Note: This test might not always fail since it depends on actual runtime memory
|
||||
// But if it does fail, verify it's the correct error type
|
||||
if err != nil {
|
||||
validateMemoryLimitError(t, err)
|
||||
t.Log("Successfully triggered memory limit violation")
|
||||
} else {
|
||||
t.Log("Memory limit check passed - actual memory usage may be within limits")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceMonitor_MemoryLimitHandling tests the memory violation detection.
|
||||
func TestResourceMonitorMemoryLimitHandling(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Enable resource limits with very small hard limit
|
||||
viper.Set(shared.TestCfgResourceLimitsEnabled, true)
|
||||
viper.Set("resourceLimits.hardMemoryLimitMB", 0.0001) // Very tiny limit
|
||||
viper.Set("resourceLimits.enableGracefulDegradation", true)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Allocate more memory to increase chances of triggering limit
|
||||
buffers := make([][]byte, 0, 100) // Pre-allocate capacity
|
||||
for i := 0; i < 100; i++ {
|
||||
buffer := make([]byte, 1024*1024) // 1MB each
|
||||
buffers = append(buffers, buffer)
|
||||
_ = buffer[0] // Use buffer
|
||||
_ = buffers // Use the slice to prevent unused variable warning
|
||||
|
||||
// Check periodically
|
||||
if i%10 == 0 {
|
||||
err := rm.CheckHardMemoryLimit()
|
||||
if err != nil {
|
||||
// Successfully triggered memory limit
|
||||
if !strings.Contains(err.Error(), "memory limit") {
|
||||
t.Errorf("Expected error message to mention memory limit, got: %v", err)
|
||||
}
|
||||
t.Log("Successfully triggered memory limit handling")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Could not trigger memory limit - actual memory usage may be lower than limit")
|
||||
}
|
||||
|
||||
// TestResourceMonitorGracefulRecovery tests graceful recovery attempts.
|
||||
func TestResourceMonitorGracefulRecovery(t *testing.T) {
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Set memory limits that will trigger recovery
|
||||
viper.Set(shared.TestCfgResourceLimitsEnabled, true)
|
||||
|
||||
rm := NewResourceMonitor()
|
||||
defer rm.Close()
|
||||
|
||||
// Force a deterministic 1-byte hard memory limit to trigger recovery
|
||||
rm.hardMemoryLimitBytes = 1
|
||||
|
||||
// Process multiple files to accumulate memory usage
|
||||
for i := 0; i < 3; i++ {
|
||||
filePath := "/tmp/test" + string(rune('1'+i)) + ".txt"
|
||||
fileSize := int64(400) // Each file is 400 bytes
|
||||
|
||||
// First few might pass, but eventually should trigger recovery mechanisms
|
||||
err := rm.ValidateFileProcessing(filePath, fileSize)
|
||||
if err != nil {
|
||||
// Once we hit the limit, test that the error is appropriate
|
||||
if !strings.Contains(err.Error(), "resource") && !strings.Contains(err.Error(), "limit") {
|
||||
t.Errorf("Expected resource limit error, got: %v", err)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
rm.RecordFileProcessed(fileSize)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,8 @@ package fileproc
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/config"
|
||||
ignore "github.com/sabhiram/go-gitignore"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Walker defines an interface for scanning directories.
|
||||
@@ -18,22 +16,31 @@ type Walker interface {
|
||||
// ProdWalker implements Walker using a custom directory walker that
|
||||
// respects .gitignore and .ignore files, configuration-defined ignore directories,
|
||||
// and ignores binary and image files by default.
|
||||
type ProdWalker struct{}
|
||||
type ProdWalker struct {
|
||||
filter *FileFilter
|
||||
}
|
||||
|
||||
// ignoreRule holds an ignore matcher along with the base directory where it was loaded.
|
||||
type ignoreRule struct {
|
||||
base string
|
||||
gi *ignore.GitIgnore
|
||||
// NewProdWalker creates a new production walker with current configuration.
|
||||
func NewProdWalker() *ProdWalker {
|
||||
return &ProdWalker{
|
||||
filter: NewFileFilter(),
|
||||
}
|
||||
}
|
||||
|
||||
// Walk scans the given root directory recursively and returns a slice of file paths
|
||||
// that are not ignored based on .gitignore/.ignore files, the configuration, or the default binary/image filter.
|
||||
func (pw ProdWalker) Walk(root string) ([]string, error) {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
func (w *ProdWalker) Walk(root string) ([]string, error) {
|
||||
absRoot, err := shared.AbsolutePath(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSPathResolution,
|
||||
"failed to resolve root path",
|
||||
).WithFilePath(root)
|
||||
}
|
||||
return walkDir(absRoot, absRoot, []ignoreRule{})
|
||||
|
||||
return w.walkDir(absRoot, []ignoreRule{})
|
||||
}
|
||||
|
||||
// walkDir recursively walks the directory tree starting at currentDir.
|
||||
@@ -41,122 +48,44 @@ func (pw ProdWalker) Walk(root string) ([]string, error) {
|
||||
// appends the corresponding rules to the inherited list. Each file/directory is
|
||||
// then checked against the accumulated ignore rules, the configuration's list of ignored directories,
|
||||
// and a default filter that ignores binary and image files.
|
||||
func walkDir(root string, currentDir string, parentRules []ignoreRule) ([]string, error) {
|
||||
func (w *ProdWalker) walkDir(currentDir string, parentRules []ignoreRule) ([]string, error) {
|
||||
var results []string
|
||||
|
||||
entries, err := os.ReadDir(currentDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeFileSystem,
|
||||
shared.CodeFSAccess,
|
||||
"failed to read directory",
|
||||
).WithFilePath(currentDir)
|
||||
}
|
||||
|
||||
// Start with the parent's ignore rules.
|
||||
rules := make([]ignoreRule, len(parentRules))
|
||||
copy(rules, parentRules)
|
||||
|
||||
// Check for .gitignore and .ignore files in the current directory.
|
||||
for _, fileName := range []string{".gitignore", ".ignore"} {
|
||||
ignorePath := filepath.Join(currentDir, fileName)
|
||||
if info, err := os.Stat(ignorePath); err == nil && !info.IsDir() {
|
||||
gi, err := ignore.CompileIgnoreFile(ignorePath)
|
||||
if err == nil {
|
||||
rules = append(rules, ignoreRule{
|
||||
base: currentDir,
|
||||
gi: gi,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the list of directories to ignore from configuration.
|
||||
ignoredDirs := config.GetIgnoredDirectories()
|
||||
sizeLimit := config.GetFileSizeLimit() // e.g., 5242880 for 5 MB
|
||||
rules := loadIgnoreRules(currentDir, parentRules)
|
||||
|
||||
for _, entry := range entries {
|
||||
fullPath := filepath.Join(currentDir, entry.Name())
|
||||
|
||||
// For directories, check if its name is in the config ignore list.
|
||||
if entry.IsDir() {
|
||||
for _, d := range ignoredDirs {
|
||||
if entry.Name() == d {
|
||||
// Skip this directory entirely.
|
||||
goto SkipEntry
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if file exceeds the configured size limit.
|
||||
info, err := entry.Info()
|
||||
if err == nil && info.Size() > sizeLimit {
|
||||
goto SkipEntry
|
||||
}
|
||||
|
||||
// For files, apply the default filter to ignore binary and image files.
|
||||
if isBinaryOrImage(fullPath) {
|
||||
goto SkipEntry
|
||||
}
|
||||
if w.filter.shouldSkipEntry(entry, fullPath, rules) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check accumulated ignore rules.
|
||||
for _, rule := range rules {
|
||||
// Compute the path relative to the base where the ignore rule was defined.
|
||||
rel, err := filepath.Rel(rule.base, fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// If the rule matches, skip this entry.
|
||||
if rule.gi.MatchesPath(rel) {
|
||||
goto SkipEntry
|
||||
}
|
||||
}
|
||||
|
||||
// If not ignored, then process the entry.
|
||||
// Process entry
|
||||
if entry.IsDir() {
|
||||
subFiles, err := walkDir(root, fullPath, rules)
|
||||
subFiles, err := w.walkDir(fullPath, rules)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeProcessing,
|
||||
shared.CodeProcessingTraversal,
|
||||
"failed to traverse subdirectory",
|
||||
).WithFilePath(fullPath)
|
||||
}
|
||||
results = append(results, subFiles...)
|
||||
} else {
|
||||
results = append(results, fullPath)
|
||||
}
|
||||
SkipEntry:
|
||||
continue
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// isBinaryOrImage checks if a file should be considered binary or an image based on its extension.
|
||||
// The check is case-insensitive.
|
||||
func isBinaryOrImage(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
// Common image file extensions.
|
||||
imageExtensions := map[string]bool{
|
||||
".png": true,
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".gif": true,
|
||||
".bmp": true,
|
||||
".tiff": true,
|
||||
".ico": true,
|
||||
".svg": true,
|
||||
".webp": true,
|
||||
}
|
||||
// Common binary file extensions.
|
||||
binaryExtensions := map[string]bool{
|
||||
".exe": true,
|
||||
".dll": true,
|
||||
".so": true,
|
||||
".bin": true,
|
||||
".dat": true,
|
||||
".zip": true,
|
||||
".tar": true,
|
||||
".gz": true,
|
||||
".7z": true,
|
||||
".rar": true,
|
||||
".DS_Store": true,
|
||||
}
|
||||
if imageExtensions[ext] || binaryExtensions[ext] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
103
fileproc/walker_test.go
Normal file
103
fileproc/walker_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package fileproc_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/testutil"
|
||||
)
|
||||
|
||||
func TestProdWalkerWithIgnore(t *testing.T) {
|
||||
// Create a temporary directory structure.
|
||||
rootDir := t.TempDir()
|
||||
|
||||
subDir := testutil.CreateTestDirectory(t, rootDir, "vendor")
|
||||
|
||||
// Write sample files
|
||||
testutil.CreateTestFiles(t, rootDir, []testutil.FileSpec{
|
||||
{Name: "file1.go", Content: "content"},
|
||||
{Name: "file2.txt", Content: "content"},
|
||||
})
|
||||
testutil.CreateTestFile(t, subDir, "file_in_vendor.txt", []byte("content")) // should be ignored
|
||||
|
||||
// .gitignore that ignores *.txt and itself
|
||||
gitignoreContent := `*.txt
|
||||
.gitignore
|
||||
`
|
||||
testutil.CreateTestFile(t, rootDir, ".gitignore", []byte(gitignoreContent))
|
||||
|
||||
// Initialize config to ignore "vendor" directory
|
||||
testutil.ResetViperConfig(t, "")
|
||||
viper.Set("ignoreDirectories", []string{"vendor"})
|
||||
|
||||
// Run walker
|
||||
w := fileproc.NewProdWalker()
|
||||
found, err := w.Walk(rootDir)
|
||||
testutil.MustSucceed(t, err, "walking directory")
|
||||
|
||||
// We expect only file1.go to appear
|
||||
if len(found) != 1 {
|
||||
t.Errorf("Expected 1 file to pass filters, got %d: %v", len(found), found)
|
||||
}
|
||||
if len(found) == 1 && filepath.Base(found[0]) != "file1.go" {
|
||||
t.Errorf("Expected file1.go, got %s", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProdWalkerBinaryCheck(t *testing.T) {
|
||||
rootDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
testutil.CreateTestFiles(t, rootDir, []testutil.FileSpec{
|
||||
{Name: "somefile.exe", Content: "fake-binary-content"},
|
||||
{Name: "keep.go", Content: "package main"},
|
||||
})
|
||||
|
||||
// Reset and load default config
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
// Reset FileTypeRegistry to ensure clean state
|
||||
fileproc.ResetRegistryForTesting()
|
||||
|
||||
// Run walker
|
||||
w := fileproc.NewProdWalker()
|
||||
found, err := w.Walk(rootDir)
|
||||
testutil.MustSucceed(t, err, "walking directory")
|
||||
|
||||
// Only "keep.go" should be returned
|
||||
if len(found) != 1 {
|
||||
t.Errorf("Expected 1 file, got %d: %v", len(found), found)
|
||||
}
|
||||
if len(found) == 1 && filepath.Base(found[0]) != "keep.go" {
|
||||
t.Errorf("Expected keep.go in results, got %s", found[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProdWalkerSizeLimit(t *testing.T) {
|
||||
rootDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
largeFileData := make([]byte, 6*1024*1024) // 6 MB
|
||||
testutil.CreateTestFile(t, rootDir, "largefile.txt", largeFileData)
|
||||
testutil.CreateTestFile(t, rootDir, "smallfile.go", []byte("package main"))
|
||||
|
||||
// Reset and load default config, which sets size limit to 5 MB
|
||||
testutil.ResetViperConfig(t, "")
|
||||
|
||||
w := fileproc.NewProdWalker()
|
||||
found, err := w.Walk(rootDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Walk returned error: %v", err)
|
||||
}
|
||||
|
||||
// We should only get the small file
|
||||
if len(found) != 1 {
|
||||
t.Errorf("Expected 1 file under size limit, got %d", len(found))
|
||||
}
|
||||
if len(found) == 1 && filepath.Base(found[0]) != "smallfile.go" {
|
||||
t.Errorf("Expected smallfile.go, got %s", found[0])
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,67 @@
|
||||
// Package fileproc provides a writer for the output of the file processor.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// FileData represents a single file's path and content.
|
||||
type FileData struct {
|
||||
Path string `json:"path" yaml:"path"`
|
||||
Content string `json:"content" yaml:"content"`
|
||||
}
|
||||
// startFormatWriter handles generic writer orchestration for any format.
|
||||
// This eliminates code duplication across format-specific writer functions.
|
||||
// Uses the FormatWriter interface defined in formats.go.
|
||||
func startFormatWriter(
|
||||
outFile *os.File,
|
||||
writeCh <-chan WriteRequest,
|
||||
done chan<- struct{},
|
||||
prefix, suffix string,
|
||||
writerFactory func(*os.File) FormatWriter,
|
||||
) {
|
||||
defer close(done)
|
||||
|
||||
// OutputData represents the full output structure.
|
||||
type OutputData struct {
|
||||
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
|
||||
Files []FileData `json:"files" yaml:"files"`
|
||||
Suffix string `json:"suffix,omitempty" yaml:"suffix,omitempty"`
|
||||
}
|
||||
writer := writerFactory(outFile)
|
||||
|
||||
// StartWriter writes the output in the specified format.
|
||||
func StartWriter(outFile *os.File, writeCh <-chan WriteRequest, done chan<- struct{}, format string, prefix, suffix string) {
|
||||
var files []FileData
|
||||
// Start writing
|
||||
if err := writer.Start(prefix, suffix); err != nil {
|
||||
shared.LogError("Failed to start writer", err)
|
||||
|
||||
// Read from channel until closed
|
||||
for req := range writeCh {
|
||||
files = append(files, FileData{Path: req.Path, Content: req.Content})
|
||||
}
|
||||
|
||||
// Create output struct
|
||||
output := OutputData{Prefix: prefix, Files: files, Suffix: suffix}
|
||||
|
||||
// Serialize based on format
|
||||
var outputData []byte
|
||||
var err error
|
||||
|
||||
switch format {
|
||||
case "json":
|
||||
outputData, err = json.MarshalIndent(output, "", " ")
|
||||
case "yaml":
|
||||
outputData, err = yaml.Marshal(output)
|
||||
case "markdown":
|
||||
outputData = []byte(formatMarkdown(output))
|
||||
default:
|
||||
err = fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logrus.Errorf("Error encoding output: %v", err)
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if _, err := outFile.Write(outputData); err != nil {
|
||||
logrus.Errorf("Error writing to file: %v", err)
|
||||
// Process files
|
||||
for req := range writeCh {
|
||||
if err := writer.WriteFile(req); err != nil {
|
||||
shared.LogError("Failed to write file", err)
|
||||
}
|
||||
}
|
||||
|
||||
close(done)
|
||||
// Close writer
|
||||
if err := writer.Close(); err != nil {
|
||||
shared.LogError("Failed to close writer", err)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMarkdown(output OutputData) string {
|
||||
markdown := "# " + output.Prefix + "\n\n"
|
||||
|
||||
for _, file := range output.Files {
|
||||
markdown += fmt.Sprintf("## File: `%s`\n```%s\n%s\n```\n\n", file.Path, detectLanguage(file.Path), file.Content)
|
||||
}
|
||||
|
||||
markdown += "# " + output.Suffix
|
||||
return markdown
|
||||
}
|
||||
|
||||
// detectLanguage tries to infer code block language from file extension.
|
||||
func detectLanguage(filename string) string {
|
||||
if len(filename) < 3 {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case len(filename) >= 3 && filename[len(filename)-3:] == ".go":
|
||||
return "go"
|
||||
case len(filename) >= 3 && filename[len(filename)-3:] == ".py":
|
||||
return "python"
|
||||
case len(filename) >= 2 && filename[len(filename)-2:] == ".c":
|
||||
return "c"
|
||||
case len(filename) >= 3 && filename[len(filename)-3:] == ".js":
|
||||
return "javascript"
|
||||
// StartWriter writes the output in the specified format with memory optimization.
|
||||
func StartWriter(outFile *os.File, writeCh <-chan WriteRequest, done chan<- struct{}, format, prefix, suffix string) {
|
||||
switch format {
|
||||
case shared.FormatMarkdown:
|
||||
startMarkdownWriter(outFile, writeCh, done, prefix, suffix)
|
||||
case shared.FormatJSON:
|
||||
startJSONWriter(outFile, writeCh, done, prefix, suffix)
|
||||
case shared.FormatYAML:
|
||||
startYAMLWriter(outFile, writeCh, done, prefix, suffix)
|
||||
default:
|
||||
return ""
|
||||
context := map[string]any{
|
||||
"format": format,
|
||||
}
|
||||
err := shared.NewStructuredError(
|
||||
shared.ErrorTypeValidation,
|
||||
shared.CodeValidationFormat,
|
||||
"unsupported format: "+format,
|
||||
"",
|
||||
context,
|
||||
)
|
||||
shared.LogError("Failed to encode output", err)
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,629 @@
|
||||
package fileproc
|
||||
package fileproc_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/ivuorinen/gibidify/fileproc"
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
func TestStartWriter_JSONOutput(t *testing.T) {
|
||||
outFile, err := os.CreateTemp("", "output.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestStartWriterFormats(t *testing.T) {
|
||||
// Define table-driven test cases
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
expectError bool
|
||||
}{
|
||||
{"JSON format", "json", false},
|
||||
{"YAML format", "yaml", false},
|
||||
{"Markdown format", "markdown", false},
|
||||
{"Invalid format", "invalid", true},
|
||||
}
|
||||
defer func(name string) {
|
||||
err := os.Remove(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(
|
||||
tc.name, func(t *testing.T) {
|
||||
data := runWriterTest(t, tc.format)
|
||||
if tc.expectError {
|
||||
verifyErrorOutput(t, data)
|
||||
} else {
|
||||
verifyValidOutput(t, data, tc.format)
|
||||
verifyPrefixSuffix(t, data)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runWriterTest executes the writer with the given format and returns the output data.
|
||||
func runWriterTest(t *testing.T, format string) []byte {
|
||||
t.Helper()
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "gibidify_test_output")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := outFile.Close(); closeErr != nil {
|
||||
t.Errorf("close temp file: %v", closeErr)
|
||||
}
|
||||
}(outFile.Name())
|
||||
if removeErr := os.Remove(outFile.Name()); removeErr != nil {
|
||||
t.Errorf("remove temp file: %v", removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
writeCh := make(chan WriteRequest)
|
||||
done := make(chan struct{})
|
||||
|
||||
go StartWriter(outFile, writeCh, done, "json", "Prefix", "Suffix")
|
||||
|
||||
writeCh <- WriteRequest{Path: "file1.go", Content: "package main"}
|
||||
writeCh <- WriteRequest{Path: "file2.py", Content: "def hello(): print('Hello')"}
|
||||
// Prepare channels
|
||||
writeCh := make(chan fileproc.WriteRequest, 2)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
// Write a couple of sample requests
|
||||
writeCh <- fileproc.WriteRequest{Path: "sample.go", Content: shared.LiteralPackageMain}
|
||||
writeCh <- fileproc.WriteRequest{Path: "example.py", Content: "def foo(): pass"}
|
||||
close(writeCh)
|
||||
<-done
|
||||
|
||||
// Start the writer
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "PREFIX", "SUFFIX")
|
||||
})
|
||||
|
||||
// Wait until writer signals completion
|
||||
wg.Wait()
|
||||
select {
|
||||
case <-doneCh: // make sure all writes finished
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal(shared.TestMsgTimeoutWriterCompletion)
|
||||
}
|
||||
|
||||
// Read output
|
||||
data, err := os.ReadFile(outFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Error reading output file: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// verifyErrorOutput checks that error cases produce no output.
|
||||
func verifyErrorOutput(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
if len(data) != 0 {
|
||||
t.Errorf("Expected no output for invalid format, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyValidOutput checks format-specific output validity.
|
||||
func verifyValidOutput(t *testing.T, data []byte, format string) {
|
||||
t.Helper()
|
||||
content := string(data)
|
||||
switch format {
|
||||
case "json":
|
||||
var outStruct fileproc.OutputData
|
||||
if err := json.Unmarshal(data, &outStruct); err != nil {
|
||||
t.Errorf("JSON unmarshal failed: %v", err)
|
||||
}
|
||||
case "yaml":
|
||||
var outStruct fileproc.OutputData
|
||||
if err := yaml.Unmarshal(data, &outStruct); err != nil {
|
||||
t.Errorf("YAML unmarshal failed: %v", err)
|
||||
}
|
||||
case "markdown":
|
||||
if !strings.Contains(content, "```") {
|
||||
t.Error("Expected markdown code fences not found")
|
||||
}
|
||||
default:
|
||||
// Unknown format - basic validation that we have content
|
||||
if len(content) == 0 {
|
||||
t.Errorf("Unexpected format %s with empty content", format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verifyPrefixSuffix checks that output contains expected prefix and suffix.
|
||||
func verifyPrefixSuffix(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "PREFIX") {
|
||||
t.Errorf("Missing prefix in output: %s", data)
|
||||
}
|
||||
if !strings.Contains(content, "SUFFIX") {
|
||||
t.Errorf("Missing suffix in output: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyPrefixSuffixWith checks that output contains expected custom prefix and suffix.
|
||||
func verifyPrefixSuffixWith(t *testing.T, data []byte, expectedPrefix, expectedSuffix string) {
|
||||
t.Helper()
|
||||
content := string(data)
|
||||
if !strings.Contains(content, expectedPrefix) {
|
||||
t.Errorf("Missing prefix '%s' in output: %s", expectedPrefix, data)
|
||||
}
|
||||
if !strings.Contains(content, expectedSuffix) {
|
||||
t.Errorf("Missing suffix '%s' in output: %s", expectedSuffix, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartWriterStreamingFormats tests streaming functionality in all writers.
|
||||
func TestStartWriterStreamingFormats(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
content string
|
||||
}{
|
||||
{"JSON streaming", "json", strings.Repeat("line\n", 1000)},
|
||||
{"YAML streaming", "yaml", strings.Repeat("data: value\n", 1000)},
|
||||
{"Markdown streaming", "markdown", strings.Repeat("# Header\nContent\n", 1000)},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(
|
||||
tc.name, func(t *testing.T) {
|
||||
data := runStreamingWriterTest(t, tc.format, tc.content)
|
||||
|
||||
// Verify output is not empty
|
||||
if len(data) == 0 {
|
||||
t.Error("Expected streaming output but got empty result")
|
||||
}
|
||||
|
||||
// Format-specific validation
|
||||
verifyValidOutput(t, data, tc.format)
|
||||
verifyPrefixSuffixWith(t, data, "STREAM_PREFIX", "STREAM_SUFFIX")
|
||||
|
||||
// Verify content was written
|
||||
content := string(data)
|
||||
if !strings.Contains(content, shared.TestFileStreamTest) {
|
||||
t.Error("Expected file path in streaming output")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// runStreamingWriterTest executes the writer with streaming content.
|
||||
func runStreamingWriterTest(t *testing.T, format, content string) []byte {
|
||||
t.Helper()
|
||||
|
||||
// Create temp file with content for streaming
|
||||
contentFile, err := os.CreateTemp(t.TempDir(), "content_*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create content file: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(contentFile.Name()); err != nil {
|
||||
t.Logf("Failed to remove content file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := contentFile.WriteString(content); err != nil {
|
||||
t.Fatalf("Failed to write content file: %v", err)
|
||||
}
|
||||
if err := contentFile.Close(); err != nil {
|
||||
t.Fatalf("Failed to close content file: %v", err)
|
||||
}
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "gibidify_stream_test_output")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := outFile.Close(); closeErr != nil {
|
||||
t.Errorf("close temp file: %v", closeErr)
|
||||
}
|
||||
if removeErr := os.Remove(outFile.Name()); removeErr != nil {
|
||||
t.Errorf("remove temp file: %v", removeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
// Prepare channels with streaming request
|
||||
writeCh := make(chan fileproc.WriteRequest, 1)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
// Create reader for streaming
|
||||
reader, err := os.Open(contentFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open content file for reading: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Logf("Failed to close reader: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Write streaming request
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: shared.TestFileStreamTest,
|
||||
Content: "", // Empty for streaming
|
||||
IsStream: true,
|
||||
Reader: reader,
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
// Start the writer
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "STREAM_PREFIX", "STREAM_SUFFIX")
|
||||
})
|
||||
|
||||
// Wait until writer signals completion
|
||||
wg.Wait()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal(shared.TestMsgTimeoutWriterCompletion)
|
||||
}
|
||||
|
||||
// Read output
|
||||
data, err := os.ReadFile(outFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Error reading output file: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// setupReadOnlyFile creates a read-only file for error testing.
|
||||
func setupReadOnlyFile(t *testing.T) (*os.File, chan fileproc.WriteRequest, chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
outPath := filepath.Join(t.TempDir(), "readonly_out")
|
||||
outFile, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
// Close writable FD and reopen as read-only so writes will fail
|
||||
_ = outFile.Close()
|
||||
outFile, err = os.OpenFile(outPath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to reopen as read-only: %v", err)
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 1)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: shared.TestFileGo,
|
||||
Content: shared.LiteralPackageMain,
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
return outFile, writeCh, doneCh
|
||||
}
|
||||
|
||||
// setupStreamingError creates a streaming request with a failing reader.
|
||||
func setupStreamingError(t *testing.T) (*os.File, chan fileproc.WriteRequest, chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "yaml_stream_*")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 1)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
if err := pw.CloseWithError(errors.New("simulated stream error")); err != nil {
|
||||
t.Fatalf("failed to set pipe error: %v", err)
|
||||
}
|
||||
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: "stream_fail.yaml",
|
||||
Content: "", // Empty for streaming
|
||||
IsStream: true,
|
||||
Reader: pr,
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
return outFile, writeCh, doneCh
|
||||
}
|
||||
|
||||
// setupSpecialCharacters creates requests with special characters.
|
||||
func setupSpecialCharacters(t *testing.T) (*os.File, chan fileproc.WriteRequest, chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "markdown_special_*")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 2)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: "special\ncharacters.md",
|
||||
Content: "Content with\x00null bytes and\ttabs",
|
||||
}
|
||||
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: "empty.md",
|
||||
Content: "",
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
return outFile, writeCh, doneCh
|
||||
}
|
||||
|
||||
// runErrorHandlingTest runs a single error handling test.
|
||||
func runErrorHandlingTest(
|
||||
t *testing.T,
|
||||
outFile *os.File,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
doneCh chan struct{},
|
||||
format string,
|
||||
expectEmpty bool,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
defer func() {
|
||||
if err := os.Remove(outFile.Name()); err != nil {
|
||||
t.Logf("Failed to remove temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
if err := outFile.Close(); err != nil {
|
||||
t.Logf("Failed to close temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "PREFIX", "SUFFIX")
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Wait for doneCh with timeout to prevent test hangs
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal(shared.TestMsgTimeoutWriterCompletion)
|
||||
}
|
||||
|
||||
// Read output file and verify based on expectation
|
||||
data, err := os.ReadFile(outFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
if expectEmpty && len(data) != 0 {
|
||||
t.Errorf("expected empty output on error, got %d bytes", len(data))
|
||||
}
|
||||
if !expectEmpty && len(data) == 0 {
|
||||
t.Error("expected non-empty output, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartWriterErrorHandling tests error scenarios in writers.
|
||||
func TestStartWriterErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
setupError func(t *testing.T) (*os.File, chan fileproc.WriteRequest, chan struct{})
|
||||
expectEmptyOutput bool
|
||||
}{
|
||||
{
|
||||
name: "JSON writer with read-only file",
|
||||
format: "json",
|
||||
setupError: setupReadOnlyFile,
|
||||
expectEmptyOutput: true,
|
||||
},
|
||||
{
|
||||
name: "YAML writer with streaming error",
|
||||
format: "yaml",
|
||||
setupError: setupStreamingError,
|
||||
expectEmptyOutput: false, // Partial writes are acceptable before streaming errors
|
||||
},
|
||||
{
|
||||
name: "Markdown writer with special characters",
|
||||
format: "markdown",
|
||||
setupError: setupSpecialCharacters,
|
||||
expectEmptyOutput: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(
|
||||
tc.name, func(t *testing.T) {
|
||||
outFile, writeCh, doneCh := tc.setupError(t)
|
||||
runErrorHandlingTest(t, outFile, writeCh, doneCh, tc.format, tc.expectEmptyOutput)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// setupCloseTest sets up files and channels for close testing.
|
||||
func setupCloseTest(t *testing.T) (*os.File, chan fileproc.WriteRequest, chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "close_test_*")
|
||||
if err != nil {
|
||||
t.Fatalf(shared.TestMsgFailedToCreateFile, err)
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 5)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: fmt.Sprintf("file%d.txt", i),
|
||||
Content: fmt.Sprintf("Content %d", i),
|
||||
}
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
return outFile, writeCh, doneCh
|
||||
}
|
||||
|
||||
// runCloseTest executes writer and validates output.
|
||||
func runCloseTest(
|
||||
t *testing.T,
|
||||
outFile *os.File,
|
||||
writeCh chan fileproc.WriteRequest,
|
||||
doneCh chan struct{},
|
||||
format string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
defer func() {
|
||||
if err := os.Remove(outFile.Name()); err != nil {
|
||||
t.Logf("Failed to remove temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
if err := outFile.Close(); err != nil {
|
||||
t.Logf("Failed to close temp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "TEST_PREFIX", "TEST_SUFFIX")
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal(shared.TestMsgTimeoutWriterCompletion)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
var output OutputData
|
||||
if err := json.Unmarshal(data, &output); err != nil {
|
||||
t.Fatalf("JSON output is invalid: %v", err)
|
||||
if len(data) == 0 {
|
||||
t.Error("Expected non-empty output file")
|
||||
}
|
||||
|
||||
if len(output.Files) != 2 {
|
||||
t.Errorf("Expected 2 files, got %d", len(output.Files))
|
||||
verifyPrefixSuffixWith(t, data, "TEST_PREFIX", "TEST_SUFFIX")
|
||||
}
|
||||
|
||||
// TestStartWriterWriterCloseErrors tests error handling during writer close operations.
|
||||
func TestStartWriterWriterCloseErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
}{
|
||||
{"JSON close handling", "json"},
|
||||
{"YAML close handling", "yaml"},
|
||||
{"Markdown close handling", "markdown"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(
|
||||
tc.name, func(t *testing.T) {
|
||||
outFile, writeCh, doneCh := setupCloseTest(t)
|
||||
runCloseTest(t, outFile, writeCh, doneCh, tc.format)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks for writer performance
|
||||
|
||||
// BenchmarkStartWriter benchmarks basic writer operations across formats.
|
||||
func BenchmarkStartWriter(b *testing.B) {
|
||||
formats := []string{"json", "yaml", "markdown"}
|
||||
|
||||
for _, format := range formats {
|
||||
b.Run(format, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
outFile, err := os.CreateTemp(b.TempDir(), "bench_output_*")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 2)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
writeCh <- fileproc.WriteRequest{Path: "sample.go", Content: shared.LiteralPackageMain}
|
||||
writeCh <- fileproc.WriteRequest{Path: "example.py", Content: "def foo(): pass"}
|
||||
close(writeCh)
|
||||
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "PREFIX", "SUFFIX")
|
||||
<-doneCh
|
||||
|
||||
_ = outFile.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// benchStreamingIteration runs a single streaming benchmark iteration.
|
||||
func benchStreamingIteration(b *testing.B, format, content string) {
|
||||
b.Helper()
|
||||
|
||||
contentFile := createBenchContentFile(b, content)
|
||||
defer func() { _ = os.Remove(contentFile) }()
|
||||
|
||||
reader, err := os.Open(contentFile)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to open content file: %v", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
outFile, err := os.CreateTemp(b.TempDir(), "bench_stream_output_*")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create output file: %v", err)
|
||||
}
|
||||
defer func() { _ = outFile.Close() }()
|
||||
|
||||
writeCh := make(chan fileproc.WriteRequest, 1)
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
writeCh <- fileproc.WriteRequest{
|
||||
Path: shared.TestFileStreamTest,
|
||||
Content: "",
|
||||
IsStream: true,
|
||||
Reader: reader,
|
||||
}
|
||||
close(writeCh)
|
||||
|
||||
fileproc.StartWriter(outFile, writeCh, doneCh, format, "PREFIX", "SUFFIX")
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
// createBenchContentFile creates a temp file with content for benchmarks.
|
||||
func createBenchContentFile(b *testing.B, content string) string {
|
||||
b.Helper()
|
||||
|
||||
contentFile, err := os.CreateTemp(b.TempDir(), "content_*")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create content file: %v", err)
|
||||
}
|
||||
if _, err := contentFile.WriteString(content); err != nil {
|
||||
b.Fatalf("Failed to write content: %v", err)
|
||||
}
|
||||
if err := contentFile.Close(); err != nil {
|
||||
b.Fatalf("Failed to close content file: %v", err)
|
||||
}
|
||||
|
||||
return contentFile.Name()
|
||||
}
|
||||
|
||||
// BenchmarkStartWriterStreaming benchmarks streaming writer operations across formats.
|
||||
func BenchmarkStartWriterStreaming(b *testing.B) {
|
||||
formats := []string{"json", "yaml", "markdown"}
|
||||
content := strings.Repeat("line content\n", 1000)
|
||||
|
||||
for _, format := range formats {
|
||||
b.Run(format, func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
benchStreamingIteration(b, format, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
129
fileproc/yaml_writer.go
Normal file
129
fileproc/yaml_writer.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package fileproc handles file processing, collection, and output formatting.
|
||||
package fileproc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// YAMLWriter handles YAML format output with streaming support.
|
||||
type YAMLWriter struct {
|
||||
outFile *os.File
|
||||
}
|
||||
|
||||
// NewYAMLWriter creates a new YAML writer.
|
||||
func NewYAMLWriter(outFile *os.File) *YAMLWriter {
|
||||
return &YAMLWriter{outFile: outFile}
|
||||
}
|
||||
|
||||
// Start writes the YAML header.
|
||||
func (w *YAMLWriter) Start(prefix, suffix string) error {
|
||||
// Write YAML header
|
||||
if _, err := fmt.Fprintf(
|
||||
w.outFile,
|
||||
"prefix: %s\nsuffix: %s\nfiles:\n",
|
||||
shared.EscapeForYAML(prefix),
|
||||
shared.EscapeForYAML(suffix),
|
||||
); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "failed to write YAML header")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteFile writes a file entry in YAML format.
|
||||
func (w *YAMLWriter) WriteFile(req WriteRequest) error {
|
||||
if req.IsStream {
|
||||
return w.writeStreaming(req)
|
||||
}
|
||||
|
||||
return w.writeInline(req)
|
||||
}
|
||||
|
||||
// Close writes the YAML footer (no footer needed for YAML).
|
||||
func (w *YAMLWriter) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStreaming writes a large file as YAML in streaming chunks.
|
||||
func (w *YAMLWriter) writeStreaming(req WriteRequest) error {
|
||||
defer shared.SafeCloseReader(req.Reader, req.Path)
|
||||
|
||||
language := detectLanguage(req.Path)
|
||||
|
||||
// Write YAML file entry start
|
||||
if _, err := fmt.Fprintf(
|
||||
w.outFile,
|
||||
shared.YAMLFmtFileEntry,
|
||||
shared.EscapeForYAML(req.Path),
|
||||
language,
|
||||
); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write YAML file start",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
// Stream content with YAML indentation
|
||||
if err := shared.StreamLines(
|
||||
req.Reader, w.outFile, req.Path, func(line string) string {
|
||||
return " " + line
|
||||
},
|
||||
); err != nil {
|
||||
return shared.WrapError(err, shared.ErrorTypeIO, shared.CodeIOWrite, "streaming YAML content")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeInline writes a small file directly as YAML.
|
||||
func (w *YAMLWriter) writeInline(req WriteRequest) error {
|
||||
language := detectLanguage(req.Path)
|
||||
fileData := FileData{
|
||||
Path: req.Path,
|
||||
Content: req.Content,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
// Write YAML entry
|
||||
if _, err := fmt.Fprintf(
|
||||
w.outFile,
|
||||
shared.YAMLFmtFileEntry,
|
||||
shared.EscapeForYAML(fileData.Path),
|
||||
fileData.Language,
|
||||
); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write YAML entry start",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
|
||||
// Write indented content
|
||||
lines := strings.Split(fileData.Content, "\n")
|
||||
for _, line := range lines {
|
||||
if _, err := fmt.Fprintf(w.outFile, " %s\n", line); err != nil {
|
||||
return shared.WrapError(
|
||||
err,
|
||||
shared.ErrorTypeIO,
|
||||
shared.CodeIOWrite,
|
||||
"failed to write YAML content line",
|
||||
).WithFilePath(req.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startYAMLWriter handles YAML format output with streaming support.
|
||||
func startYAMLWriter(outFile *os.File, writeCh <-chan WriteRequest, done chan<- struct{}, prefix, suffix string) {
|
||||
startFormatWriter(outFile, writeCh, done, prefix, suffix, func(f *os.File) FormatWriter {
|
||||
return NewYAMLWriter(f)
|
||||
})
|
||||
}
|
||||
45
go.mod
45
go.mod
@@ -1,33 +1,34 @@
|
||||
module github.com/ivuorinen/gibidify
|
||||
|
||||
go 1.23
|
||||
go 1.25
|
||||
|
||||
toolchain go1.25.6
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/schollz/progressbar/v3 v3.19.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/viper v1.21.0
|
||||
golang.org/x/text v0.33.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
)
|
||||
|
||||
112
go.sum
112
go.sum
@@ -1,86 +1,72 @@
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
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/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegrationFullCLI simulates a full run of the CLI application using adaptive concurrency.
|
||||
func TestIntegrationFullCLI(t *testing.T) {
|
||||
// Create a temporary source directory and populate it with test files.
|
||||
srcDir, err := ioutil.TempDir("", "gibidify_src")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp source directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(srcDir)
|
||||
|
||||
// Create two test files.
|
||||
file1 := filepath.Join(srcDir, "file1.txt")
|
||||
if err := ioutil.WriteFile(file1, []byte("Hello World"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file1: %v", err)
|
||||
}
|
||||
file2 := filepath.Join(srcDir, "file2.go")
|
||||
if err := ioutil.WriteFile(file2, []byte("package main\nfunc main() {}"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file2: %v", err)
|
||||
}
|
||||
|
||||
// Create a temporary output file.
|
||||
outFile, err := ioutil.TempFile("", "gibidify_output.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp output file: %v", err)
|
||||
}
|
||||
outFilePath := outFile.Name()
|
||||
outFile.Close()
|
||||
defer os.Remove(outFilePath)
|
||||
|
||||
// Set up CLI arguments.
|
||||
os.Args = []string{
|
||||
"gibidify",
|
||||
"-source", srcDir,
|
||||
"-destination", outFilePath,
|
||||
"-prefix", "PREFIX",
|
||||
"-suffix", "SUFFIX",
|
||||
"-concurrency", "2", // For testing, set concurrency to 2.
|
||||
}
|
||||
|
||||
// Run the application with a background context.
|
||||
ctx := context.Background()
|
||||
if err := Run(ctx); err != nil {
|
||||
t.Fatalf("Run failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the output file contains the expected prefix, file contents, and suffix.
|
||||
data, err := ioutil.ReadFile(outFilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
output := string(data)
|
||||
if !strings.Contains(output, "PREFIX") {
|
||||
t.Error("Output missing prefix")
|
||||
}
|
||||
if !strings.Contains(output, "Hello World") {
|
||||
t.Error("Output missing content from file1.txt")
|
||||
}
|
||||
if !strings.Contains(output, "SUFFIX") {
|
||||
t.Error("Output missing suffix")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationCancellation verifies that the application correctly cancels processing when the context times out.
|
||||
func TestIntegrationCancellation(t *testing.T) {
|
||||
// Create a temporary source directory with many files to simulate a long-running process.
|
||||
srcDir, err := ioutil.TempDir("", "gibidify_src_long")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp source directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(srcDir)
|
||||
|
||||
// Create a large number of small files.
|
||||
for i := 0; i < 1000; i++ {
|
||||
filePath := filepath.Join(srcDir, fmt.Sprintf("file%d.txt", i))
|
||||
if err := ioutil.WriteFile(filePath, []byte("Content"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write %s: %v", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temporary output file.
|
||||
outFile, err := ioutil.TempFile("", "gibidify_output.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp output file: %v", err)
|
||||
}
|
||||
outFilePath := outFile.Name()
|
||||
outFile.Close()
|
||||
defer os.Remove(outFilePath)
|
||||
|
||||
// Set up CLI arguments.
|
||||
os.Args = []string{
|
||||
"gibidify",
|
||||
"-source", srcDir,
|
||||
"-destination", outFilePath,
|
||||
"-prefix", "PREFIX",
|
||||
"-suffix", "SUFFIX",
|
||||
"-concurrency", "2",
|
||||
}
|
||||
|
||||
// Create a context with a very short timeout to force cancellation.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// Run the application; we expect an error due to cancellation.
|
||||
err = Run(ctx)
|
||||
if err == nil {
|
||||
t.Error("Expected Run to fail due to cancellation, but it succeeded")
|
||||
}
|
||||
}
|
||||
217
interfaces.go
Normal file
217
interfaces.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Package main provides core interfaces for the gibidify application.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/ivuorinen/gibidify/shared"
|
||||
)
|
||||
|
||||
// Processor defines the interface for file processors.
|
||||
// This interface allows for easier testing and mocking of the main processing logic.
|
||||
type Processor interface {
|
||||
// Process starts the file processing workflow with the given context.
|
||||
// It returns an error if processing fails at any stage.
|
||||
Process(ctx context.Context) error
|
||||
}
|
||||
|
||||
// FileProcessorInterface defines the interface for individual file processing.
|
||||
// This abstracts the file processing logic for better testability.
|
||||
type FileProcessorInterface interface {
|
||||
// ProcessFile processes a single file and sends the result to the output channel.
|
||||
ProcessFile(ctx context.Context, filePath string, outCh chan<- WriteRequest)
|
||||
|
||||
// ProcessWithContext processes a file and returns the content directly.
|
||||
ProcessWithContext(ctx context.Context, filePath string) (string, error)
|
||||
}
|
||||
|
||||
// ResourceMonitorInterface defines the interface for resource monitoring.
|
||||
// This allows for mocking and testing of resource management functionality.
|
||||
type ResourceMonitorInterface interface {
|
||||
// Start begins resource monitoring.
|
||||
Start() error
|
||||
|
||||
// Stop stops resource monitoring and cleanup.
|
||||
Stop() error
|
||||
|
||||
// CheckResourceLimits validates current resource usage against limits.
|
||||
CheckResourceLimits() error
|
||||
|
||||
// Metrics returns current resource usage metrics.
|
||||
Metrics() ResourceMetrics
|
||||
}
|
||||
|
||||
// MetricsCollectorInterface defines the interface for metrics collection.
|
||||
// This enables easier testing and different metrics backend implementations.
|
||||
type MetricsCollectorInterface interface {
|
||||
// RecordFileProcessed records the processing of a single file.
|
||||
RecordFileProcessed(result FileProcessingResult)
|
||||
|
||||
// IncrementConcurrency increments the current concurrency counter.
|
||||
IncrementConcurrency()
|
||||
|
||||
// DecrementConcurrency decrements the current concurrency counter.
|
||||
DecrementConcurrency()
|
||||
|
||||
// CurrentMetrics returns the current processing metrics.
|
||||
CurrentMetrics() ProcessingMetrics
|
||||
|
||||
// GenerateReport generates a comprehensive processing report.
|
||||
GenerateReport() ProfileReport
|
||||
|
||||
// Reset resets all metrics to initial state.
|
||||
Reset()
|
||||
}
|
||||
|
||||
// UIManagerInterface defines the interface for user interface management.
|
||||
// This abstracts UI operations for better testing and different UI implementations.
|
||||
type UIManagerInterface interface {
|
||||
// PrintInfo prints an informational message.
|
||||
PrintInfo(message string)
|
||||
|
||||
// PrintWarning prints a warning message.
|
||||
PrintWarning(message string)
|
||||
|
||||
// PrintError prints an error message.
|
||||
PrintError(message string)
|
||||
|
||||
// PrintSuccess prints a success message.
|
||||
PrintSuccess(message string)
|
||||
|
||||
// SetColorOutput enables or disables colored output.
|
||||
SetColorOutput(enabled bool)
|
||||
|
||||
// SetProgressOutput enables or disables progress indicators.
|
||||
SetProgressOutput(enabled bool)
|
||||
}
|
||||
|
||||
// WriterInterface defines the interface for output writers.
|
||||
// This allows for different output formats and destinations.
|
||||
type WriterInterface interface {
|
||||
// Write writes the processed content to the destination.
|
||||
Write(req WriteRequest) error
|
||||
|
||||
// Close finalizes the output and closes any resources.
|
||||
Close() error
|
||||
|
||||
// GetFormat returns the output format supported by this writer.
|
||||
GetFormat() string
|
||||
}
|
||||
|
||||
// BackpressureManagerInterface defines the interface for backpressure management.
|
||||
// This abstracts memory and flow control for better testing.
|
||||
type BackpressureManagerInterface interface {
|
||||
// CheckBackpressure returns true if backpressure should be applied.
|
||||
CheckBackpressure() bool
|
||||
|
||||
// UpdateMemoryUsage updates the current memory usage tracking.
|
||||
UpdateMemoryUsage(bytes int64)
|
||||
|
||||
// GetMemoryUsage returns current memory usage statistics.
|
||||
GetMemoryUsage() int64
|
||||
|
||||
// Reset resets backpressure state to initial values.
|
||||
Reset()
|
||||
}
|
||||
|
||||
// TemplateEngineInterface defines the interface for template processing.
|
||||
// This allows for different templating systems and easier testing.
|
||||
type TemplateEngineInterface interface {
|
||||
// RenderHeader renders the document header using the configured template.
|
||||
RenderHeader(ctx TemplateContext) (string, error)
|
||||
|
||||
// RenderFooter renders the document footer using the configured template.
|
||||
RenderFooter(ctx TemplateContext) (string, error)
|
||||
|
||||
// RenderFileContent renders individual file content with formatting.
|
||||
RenderFileContent(ctx FileContext) (string, error)
|
||||
|
||||
// RenderMetadata renders metadata section if enabled.
|
||||
RenderMetadata(ctx TemplateContext) (string, error)
|
||||
}
|
||||
|
||||
// ConfigLoaderInterface defines the interface for configuration management.
|
||||
// This enables different configuration sources and easier testing.
|
||||
type ConfigLoaderInterface interface {
|
||||
// LoadConfig loads configuration from the appropriate source.
|
||||
LoadConfig() error
|
||||
|
||||
// GetString returns a string configuration value.
|
||||
GetString(key string) string
|
||||
|
||||
// GetInt returns an integer configuration value.
|
||||
GetInt(key string) int
|
||||
|
||||
// GetBool returns a boolean configuration value.
|
||||
GetBool(key string) bool
|
||||
|
||||
// GetStringSlice returns a string slice configuration value.
|
||||
GetStringSlice(key string) []string
|
||||
}
|
||||
|
||||
// LoggerInterface defines the interface for logging operations.
|
||||
// This abstracts logging for better testing and different log backends.
|
||||
type LoggerInterface = shared.Logger
|
||||
|
||||
// These types are referenced by the interfaces but need to be defined
|
||||
// elsewhere in the codebase. They are included here for documentation.
|
||||
|
||||
type WriteRequest struct {
|
||||
Path string
|
||||
Content string
|
||||
IsStream bool
|
||||
Reader io.Reader
|
||||
Size int64
|
||||
}
|
||||
|
||||
type ResourceMetrics struct {
|
||||
FilesProcessed int64
|
||||
TotalSizeProcessed int64
|
||||
ConcurrentReads int64
|
||||
MaxConcurrentReads int64
|
||||
}
|
||||
|
||||
type FileProcessingResult struct {
|
||||
FilePath string
|
||||
FileSize int64
|
||||
Format string
|
||||
Success bool
|
||||
Error error
|
||||
Skipped bool
|
||||
SkipReason string
|
||||
}
|
||||
|
||||
type ProcessingMetrics struct {
|
||||
TotalFiles int64
|
||||
ProcessedFiles int64
|
||||
ErrorFiles int64
|
||||
SkippedFiles int64
|
||||
TotalSize int64
|
||||
ProcessedSize int64
|
||||
}
|
||||
|
||||
type ProfileReport struct {
|
||||
Summary ProcessingMetrics
|
||||
// Additional report fields would be defined in the metrics package
|
||||
}
|
||||
|
||||
type TemplateContext struct {
|
||||
Files []FileContext
|
||||
// Additional context fields would be defined in the templates package
|
||||
}
|
||||
|
||||
type FileContext struct {
|
||||
Path string
|
||||
Content string
|
||||
// Additional file context fields would be defined in the templates package
|
||||
}
|
||||
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = iota
|
||||
LogLevelInfo
|
||||
LogLevelWarn
|
||||
LogLevelError
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user