190 Commits
0.0.1 ... main

Author SHA1 Message Date
renovate[bot]
e385319445 chore(actions): update github/codeql-action action (v4.32.6 → v4.33.0) (#198)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-18 05:23:58 +00:00
Copilot
1ce47441ec fix(security): suppress gosec G118 false positives and fix govulncheck stdlib vulnerabilities (#197) 2026-03-15 17:41:43 +02:00
renovate[bot]
a746d4f504 chore(actions): update ivuorinen/actions action (v2026.03.11 → v2026.03.14) (#196)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-15 05:33:42 +00:00
renovate[bot]
c8ce1dac72 fix(deps): update module golang.org/x/text (v0.34.0 → v0.35.0) (#195)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 08:43:18 +00:00
renovate[bot]
18921311a5 chore(actions): update ivuorinen/actions action (v2026.03.10 → v2026.03.11) (#194)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-12 04:38:04 +00:00
renovate[bot]
261312ef70 chore(actions): update ivuorinen/actions action (v2026.03.09 → v2026.03.10) (#193)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-11 03:54:06 +00:00
renovate[bot]
a2fd923c7a chore(actions): update ivuorinen/actions action (v2026.03.08 → v2026.03.09) (#192)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-10 01:17:40 +00:00
renovate[bot]
96c6d12716 chore(deps): update pre-commit hook editorconfig-checker/editorconfig-checker.python (3.6.0 → 3.6.1) (#191)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 10:48:32 +00:00
renovate[bot]
f3a0f288af chore(actions): update ivuorinen/actions action (v2026.03.07 → v2026.03.08) (#190)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-09 06:12:50 +00:00
renovate[bot]
73b4c392a2 chore(deps): update dependency go (1.26.0 → 1.26.1) (#189)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 13:51:15 +00:00
renovate[bot]
3b138743e4 chore(actions): update ivuorinen/actions action (v2026.03.06 → v2026.03.07) (#188)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-08 06:01:12 +00:00
renovate[bot]
effac46625 chore(deps)!: update docker/setup-buildx-action (v3.12.0 → v4.0.0) (#185)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 18:46:44 +02:00
fff486eaf3 ci: migrate CodeQL to ivuorinen/actions/codeql-analysis (#184)
* ci: migrate codeql to composable workflow

* fix: correct codeql workflow language, queries, permissions, and action ref

- Use 'javascript' instead of 'javascript-typescript' for CodeQL language
- Add queries: security-and-quality parameter
- Set root-level permissions to {}
- Add job-level permissions (actions, contents, packages, security-events)
- Pin action ref to commit hash with version comment
- Fix mangled cron schedule
2026-03-07 18:43:35 +02:00
renovate[bot]
c771b85e68 chore(deps): update github/codeql-action action (v4.32.5 → v4.32.6) (#187)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 16:36:34 +00:00
renovate[bot]
acd100f7dc chore(deps): update ivuorinen/actions action (v2026.03.02 → v2026.03.06) (#186)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-07 14:02:32 +00:00
renovate[bot]
a36e19f590 chore(deps): update github/codeql-action action (v4.32.4 → v4.32.5) (#183)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 10:41:50 +00:00
renovate[bot]
330180890e chore(deps): update ivuorinen/actions action (v2026.02.28 → v2026.03.02) (#182)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 05:17:06 +00:00
renovate[bot]
9abf352a9c chore(deps): update securego/gosec action (v2.23.0 → v2.24.7) (#181)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-02 00:55:20 +00:00
renovate[bot]
726e938057 chore(deps): update ivuorinen/actions action (v2026.02.24 → v2026.02.28) (#180)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-01 21:52:05 +00:00
renovate[bot]
8f2afd62cb chore(deps): update actions/setup-go action (v6.2.0 → v6.3.0) (#179)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 04:17:26 +02:00
renovate[bot]
98d52ed75f chore(deps)!: update actions/upload-artifact (v6.0.0 → v7.0.0) (#178)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-27 02:51:28 +02:00
renovate[bot]
59ab6de505 chore(deps): update ivuorinen/actions action (v2026.02.23 → v2026.02.24) (#177)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 01:02:08 +00:00
renovate[bot]
0bd3be66a0 chore(deps): update ivuorinen/actions action (v2026.02.18 → v2026.02.23) (#176)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 00:55:41 +00:00
renovate[bot]
3ab1180cee chore(deps): update github/codeql-action action (v4.32.3 → v4.32.4) (#175)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-22 04:41:23 +00:00
renovate[bot]
a1f30550e1 chore(deps): update ivuorinen/actions action (v2026.02.17 → v2026.02.18) (#174)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 04:59:55 +00:00
renovate[bot]
3e6c4d963e chore(deps): update ivuorinen/actions action (v2026.02.16 → v2026.02.17) (#173)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 02:33:45 +00:00
f2fc7cded1 chore: add CODEOWNERS file for repository ownership 2026-02-18 01:17:54 +02:00
renovate[bot]
848a78b771 chore(deps): update github/codeql-action action (v4.32.1 → v4.32.3) (#167)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 01:16:55 +02:00
4559d1b7fc feat: update Go version from 1.25.6 to 1.25.7 (#172) 2026-02-18 01:10:58 +02:00
renovate[bot]
691cccf40a chore(deps): update securego/gosec action (v2.22.11 → v2.23.0) (#170)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 19:04:43 +02:00
renovate[bot]
7f057c5707 chore(deps): update ivuorinen/actions action (v2026.02.10 → v2026.02.16) (#171)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 01:03:21 +00:00
renovate[bot]
7a87d8c3b8 chore(deps): update go (1.25.6 → 1.26.0) (#166)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 20:39:54 +02:00
renovate[bot]
8ecf02605b fix(deps): update module golang.org/x/text (v0.33.0 → v0.34.0) (#168)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 19:56:28 +02:00
renovate[bot]
0f57e87d3d chore(deps): update ivuorinen/actions action (v2026.02.03 → v2026.02.10) (#169)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-11 05:26:47 +00:00
renovate[bot]
9c3de701a6 chore(deps): update ivuorinen/actions action (v2026.01.21 → v2026.02.03) (#165)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 10:05:40 +00:00
renovate[bot]
c07b0a9101 chore(deps): update github/codeql-action action (v4.32.0 → v4.32.1) (#164)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 04:31:07 +00:00
renovate[bot]
9ff22c76b0 chore(deps): update image alpine to v3.23.3 (#162)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-01 22:13:14 +02:00
994099137a fix: security issues and use gitleaks (#163)
* fix(tests): remove unused test constants and helpers

Delete dead test code that caused 41 staticcheck U1000 violations:
- cli/test_constants.go (25 unused constants)
- cli/terminal_test_helpers.go (unused type, method, 7 variables)
- fileproc/test_constants.go (5 unused constants)
- fileproc/processor_test.go (2 unused helper functions)

* fix(security): replace custom secret detection with gitleaks

The hand-rolled check_secrets regex patterns produced false positives
on configKey test values, causing make security-full to fail.

Replace with gitleaks via go run for proper secret detection with
built-in rules and allowlist support for generated report files.

* chore(deps): update dependencies and fix install-tools

Update Go module dependencies to latest versions.
Fix checkmake install path and remove yamllint go install
(yamllint is a Python tool, not installable via go install).

* docs: add design document for gitleaks integration

* feat: update go to 1.25.6
2026-02-01 22:09:24 +02:00
renovate[bot]
7a99534252 chore(deps): update github/codeql-action action (v4.31.11 → v4.32.0) (#161)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-28 06:13:12 +00:00
renovate[bot]
1f49598a1f chore(deps): update github/codeql-action action (v4.31.10 → v4.31.11) (#160)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-25 12:27:45 +00:00
renovate[bot]
13c7af276a chore(deps): update actions/checkout action (v6.0.1 → v6.0.2) (#159)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-24 04:27:56 +00:00
renovate[bot]
cf6ab06762 chore(deps): update ivuorinen/actions action (v2026.01.20 → v2026.01.21) (#158)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 05:48:56 +00:00
renovate[bot]
1387e174d7 chore(deps): update ivuorinen/actions action (v2026.01.13 → v2026.01.20) (#157)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-21 01:46:58 +00:00
renovate[bot]
7bf58ed7d4 fix(deps): update module github.com/sirupsen/logrus (v1.9.3 → v1.9.4) (#156)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 04:28:57 +00:00
renovate[bot]
b607ad8af4 chore(deps): update actions/setup-go action (v6.1.0 → v6.2.0) (#155)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 09:41:34 +00:00
renovate[bot]
89d16ad90b chore(deps): update ivuorinen/actions action (v2026.01.12 → v2026.01.13) (#154)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 04:35:30 +00:00
renovate[bot]
d9eed9b972 chore(deps): update github/codeql-action action (v4.31.9 → v4.31.10) (#153)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 10:34:18 +00:00
renovate[bot]
e7b3b82816 chore(deps): update ivuorinen/actions action (v2026.01.09 → v2026.01.12) (#152)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 04:57:22 +00:00
renovate[bot]
894ac72908 fix(deps): update module golang.org/x/text (v0.32.0 → v0.33.0) (#151)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-10 09:31:26 +00:00
renovate[bot]
6fcdcd2ea1 chore(deps): update ivuorinen/actions action (v2026.01.08 → v2026.01.09) (#150)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-10 05:30:03 +00:00
renovate[bot]
248e30cdf7 chore(deps)!: update ivuorinen/actions (v2025.12.31 → v2026.01.08) (#149) 2026-01-09 00:33:05 +02:00
renovate[bot]
d8eda9d6bb chore(deps): update ivuorinen/actions action (v2025.12.30 → v2025.12.31) (#148)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-01 04:38:55 +00:00
renovate[bot]
928632f5bc chore(deps): update ivuorinen/actions action (v2025.12.29 → v2025.12.30) (#147)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-31 04:59:40 +00:00
renovate[bot]
e5f1ccb1b4 chore(deps): update ivuorinen/actions action (v2025.12.28 → v2025.12.29) (#146)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-30 05:04:59 +00:00
renovate[bot]
a078916f3c chore(deps): update ivuorinen/actions action (v2025.12.27 → v2025.12.28) (#145)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 01:11:44 +00:00
renovate[bot]
e4d15bd590 fix(deps): update module github.com/schollz/progressbar/v3 (v3.18.0 → v3.19.0) (#144)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-28 21:35:19 +00:00
renovate[bot]
b2b3bae86b chore(deps): update ivuorinen/actions action (v2025.12.22 → v2025.12.27) (#143)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-28 17:37:36 +00:00
renovate[bot]
dd10f78aa8 chore(deps): update ivuorinen/actions action (v2025.12.21 → v2025.12.22) (#142)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-23 02:07:40 +00:00
renovate[bot]
e6d8b39fe7 chore(deps): update ivuorinen/actions action (v2025.12.20 → v2025.12.21) (#141)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-22 02:46:22 +00:00
renovate[bot]
6f0336eae4 chore(deps): update ivuorinen/actions action (v2025.12.19 → v2025.12.20) (#140)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-21 08:46:24 +00:00
renovate[bot]
407dd44725 chore(deps): update docker/setup-buildx-action action (v3.11.1 → v3.12.0) (#139)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 06:31:08 +00:00
renovate[bot]
7fe10fa4ce chore(deps): update ivuorinen/actions action (v2025.12.18 → v2025.12.19) (#138)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-20 02:08:57 +00:00
renovate[bot]
f1587ec640 chore(deps): update image alpine to v3.23.2 (#137)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 09:41:36 +00:00
renovate[bot]
99d08a6984 chore(deps): update ivuorinen/actions action (v2025.12.17 → v2025.12.18) (#136)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 05:44:59 +00:00
renovate[bot]
3c015d69e7 chore(deps): update ivuorinen/actions action (v2025.12.16 → v2025.12.17) (#135)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 01:13:21 +00:00
renovate[bot]
bb843d4b4a chore(deps): update github/codeql-action action (v4.31.8 → v4.31.9) (#134)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 06:35:20 +00:00
renovate[bot]
b47b9514a4 chore(deps): update ivuorinen/actions action (v2025.12.15 → v2025.12.16) (#133)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-17 04:15:33 +00:00
renovate[bot]
83b57a9846 chore(deps): update ivuorinen/actions action (v2025.12.14 → v2025.12.15) (#132)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 02:59:56 +00:00
renovate[bot]
b2444f5187 chore(deps): update ivuorinen/actions action (v2025.12.13 → v2025.12.14) (#131)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 02:45:24 +00:00
renovate[bot]
7c1aa2e218 chore(deps): update ivuorinen/actions action (v2025.12.12 → v2025.12.13) (#130)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 06:04:48 +00:00
renovate[bot]
31b5375aa0 chore(deps)!: update actions/upload-artifact (v5.0.0 → v6.0.0) (#127) 2025-12-13 11:55:36 +02:00
renovate[bot]
6e089badde chore(deps): update github/codeql-action action (v4.31.7 → v4.31.8) (#129)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 09:07:58 +00:00
renovate[bot]
75641223b4 chore(deps): update ivuorinen/actions action (v2025.12.11 → v2025.12.12) (#128)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 04:37:33 +00:00
renovate[bot]
b8963713d4 chore(deps): update securego/gosec action (v2.22.10 → v2.22.11) (#126)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 06:14:05 +00:00
renovate[bot]
04e803789c chore(deps): update ivuorinen/actions action (v2025.12.10 → v2025.12.11) (#125)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 02:39:29 +00:00
renovate[bot]
4b33faba40 chore(deps): update image alpine to v3.23.0 (#122)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 13:12:50 +02:00
renovate[bot]
5f5255ce16 chore(deps): update pre-commit hook editorconfig-checker/editorconfig-checker.python (3.4.0 → 3.6.0) (#124)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:06:50 +00:00
renovate[bot]
550c89bf78 chore(deps): update ivuorinen/actions action (v2025.12.09 → v2025.12.10) (#123)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 04:36:05 +00:00
95b7ef6dd3 chore: modernize workflows, security scanning, and linting configuration (#50)
* build: update Go 1.25, CI workflows, and build tooling

- Upgrade to Go 1.25
- Add benchmark targets to Makefile
- Implement parallel gosec execution
- Lock tool versions for reproducibility
- Add shellcheck directives to scripts
- Update CI workflows with improved caching

* refactor: migrate from golangci-lint to revive

- Replace golangci-lint with revive for linting
- Configure comprehensive revive rules
- Fix all EditorConfig violations
- Add yamllint and yamlfmt support
- Remove deprecated .golangci.yml

* refactor: rename utils to shared and deduplicate code

- Rename utils package to shared
- Add shared constants package
- Deduplicate constants across packages
- Address CodeRabbit review feedback

* fix: resolve SonarQube issues and add safety guards

- Fix all 73 SonarQube OPEN issues
- Add nil guards for resourceMonitor, backpressure, metricsCollector
- Implement io.Closer for headerFileReader
- Propagate errors from processing helpers
- Add metrics and templates packages
- Improve error handling across codebase

* test: improve test infrastructure and coverage

- Add benchmarks for cli, fileproc, metrics
- Improve test coverage for cli, fileproc, config
- Refactor tests with helper functions
- Add shared test constants
- Fix test function naming conventions
- Reduce cognitive complexity in benchmark tests

* docs: update documentation and configuration examples

- Update CLAUDE.md with current project state
- Refresh README with new features
- Add usage and configuration examples
- Add SonarQube project configuration
- Consolidate config.example.yaml

* fix: resolve shellcheck warnings in scripts

- Use ./*.go instead of *.go to prevent dash-prefixed filenames
  from being interpreted as options (SC2035)
- Remove unreachable return statement after exit (SC2317)
- Remove obsolete gibidiutils/ directory reference

* chore(deps): upgrade go dependencies

* chore(lint): megalinter fixes

* fix: improve test coverage and fix file descriptor leaks

- Add defer r.Close() to fix pipe file descriptor leaks in benchmark tests
- Refactor TestProcessorConfigureFileTypes with helper functions and assertions
- Refactor TestProcessorLogFinalStats with output capture and keyword verification
- Use shared constants instead of literal strings (TestFilePNG, FormatMarkdown, etc.)
- Reduce cognitive complexity by extracting helper functions

* fix: align test comments with function names

Remove underscores from test comments to match actual function names:
- benchmark/benchmark_test.go (2 fixes)
- fileproc/filetypes_config_test.go (4 fixes)
- fileproc/filetypes_registry_test.go (6 fixes)
- fileproc/processor_test.go (6 fixes)
- fileproc/resource_monitor_types_test.go (4 fixes)
- fileproc/writer_test.go (3 fixes)

* fix: various test improvements and bug fixes

- Remove duplicate maxCacheSize check in filetypes_registry_test.go
- Shorten long comment in processor_test.go to stay under 120 chars
- Remove flaky time.Sleep in collector_test.go, use >= 0 assertion
- Close pipe reader in benchmark_test.go to fix file descriptor leak
- Use ContinueOnError in flags_test.go to match ResetFlags behavior
- Add nil check for p.ui in processor_workers.go before UpdateProgress
- Fix resource_monitor_validation_test.go by setting hardMemoryLimitBytes directly

* chore(yaml): add missing document start markers

Add --- document start to YAML files to satisfy yamllint:
- .github/workflows/codeql.yml
- .github/workflows/build-test-publish.yml
- .github/workflows/security.yml
- .github/actions/setup/action.yml

* fix: guard nil resourceMonitor and fix test deadlock

- Guard resourceMonitor before CreateFileProcessingContext call
- Add ui.UpdateProgress on emergency stop and path error returns
- Fix potential deadlock in TestProcessFile using wg.Go with defer close
2025-12-10 19:07:11 +02:00
renovate[bot]
ea4a39a360 chore(deps): update ivuorinen/actions action (v2025.12.08 → v2025.12.09) (#121)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-10 05:44:58 +00:00
renovate[bot]
ea10379087 chore(deps): update ivuorinen/actions action (v2025.12.07 → v2025.12.08) (#120)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 04:31:21 +00:00
renovate[bot]
58fbb9907d chore(deps): update pre-commit hook golangci/golangci-lint (v2.7.1 → v2.7.2) (#119)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 06:24:08 +00:00
renovate[bot]
6d7a23c21a chore(deps): update ivuorinen/actions action (v2025.12.06 → v2025.12.07) (#118)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 02:07:35 +00:00
renovate[bot]
32a399ea24 chore(deps): update image alpine to v3.23.0 (#112)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 06:16:57 +02:00
renovate[bot]
f101fd53ea chore(deps): update pre-commit hook golangci/golangci-lint (v2.6.2 → v2.7.1) (#113)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 06:16:29 +02:00
87f0cdb44f feat: upgrade go, packages and actions (#116)
* chore: upgrade go and packages

* chore: upgrade actions

* fix(ci): use go version from .go-version

* fix: backpressure tests optimization
2025-12-07 06:10:33 +02:00
renovate[bot]
f56685ce62 chore(deps): update ivuorinen/actions action (v2025.12.05 → v2025.12.06) (#117)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 00:41:28 +00:00
renovate[bot]
8d098eb35d chore(deps): update github/codeql-action action (v4.31.6 → v4.31.7) (#115)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 08:39:29 +00:00
renovate[bot]
66f24dbbb4 chore(deps): update ivuorinen/actions action (v2025.12.03 → v2025.12.05) (#114)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 05:15:50 +00:00
renovate[bot]
c9c67149e7 chore(deps): update image golang to v1.25.5 (#111)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 06:45:46 +00:00
renovate[bot]
c7182e6d00 chore(deps): update ivuorinen/actions action (v2025.12.01 → v2025.12.03) (#110)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 04:12:28 +00:00
renovate[bot]
e05e1b5a58 chore(deps)!: update actions/checkout (v5.0.1 → v6.0.1) (#99) 2025-12-03 20:20:55 +02:00
renovate[bot]
a0f0844555 chore(deps): update github/codeql-action action (v4.31.5 → v4.31.6) (#109)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 10:38:10 +00:00
renovate[bot]
8cac3db14d chore(deps): update ivuorinen/actions action (v2025.11.30 → v2025.12.01) (#108)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 05:11:07 +00:00
renovate[bot]
644725cb70 chore(deps): update pre-commit hook editorconfig-checker/editorconfig-checker.python (3.5.0 → 3.6.0) (#107)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 09:03:45 +00:00
renovate[bot]
0a5840cdfc chore(deps): update ivuorinen/actions action (v2025.11.29 → v2025.11.30) (#106)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 00:36:13 +00:00
renovate[bot]
aa3930a538 chore(deps): update ivuorinen/actions action (v2025.11.28 → v2025.11.29) (#105)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-30 00:55:50 +00:00
renovate[bot]
58098ef4c5 chore(deps): update ivuorinen/actions action (v2025.11.23 → v2025.11.28) (#104)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-29 02:01:08 +00:00
renovate[bot]
b80488307e chore(deps): update github/codeql-action action (v4.31.4 → v4.31.5) (#103)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 07:43:45 +02:00
renovate[bot]
7111746c27 chore(deps): update pre-commit hook editorconfig-checker/editorconfig-checker.python (3.4.1 → 3.5.0) (#102)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 01:01:10 +00:00
renovate[bot]
0921746406 chore(deps): update ivuorinen/actions action (v2025.11.02 → v2025.11.23) (#101)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-23 21:59:32 +00:00
renovate[bot]
6eebf4a2e8 chore(deps): update actions/setup-go action (v6.0.0 → v6.1.0) (#100)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-22 05:09:48 +00:00
renovate[bot]
7e53128958 chore(deps): update github/codeql-action action (v4.31.3 → v4.31.4) (#98)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 12:31:59 +00:00
renovate[bot]
2b6ff1d2d8 chore(deps): update actions/checkout action (v5.0.0 → v5.0.1) (#97)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 04:34:17 +00:00
renovate[bot]
ae5cc1efc3 chore(deps): update pre-commit hook golangci/golangci-lint (v2.6.0 → v2.6.2) (#93)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 13:55:11 +00:00
renovate[bot]
65e33e14f2 chore(deps): update image golang to v1.25.4 (#94)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 09:37:12 +00:00
renovate[bot]
35ca8e3cff chore(deps): update github/codeql-action action (v4.31.2 → v4.31.3) (#96)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 06:55:27 +00:00
Copilot
bec064d0b7 fix: refactor Makefile to fix checkmake maxbodylength violations (#95) 2025-11-11 22:32:21 +02:00
renovate[bot]
3a122c3d0c chore(deps): update ivuorinen/actions action (v2025.10.26 → v2025.11.02) (#92)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 02:26:16 +00:00
renovate[bot]
f1a47c4830 chore(deps): update pre-commit hook golangci/golangci-lint (v2.5.0 → v2.6.0) (#90)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 16:41:28 +00:00
renovate[bot]
745c48a1de chore(deps): update github/codeql-action action (v4.31.0 → v4.31.2) (#91)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-01 12:25:48 +00:00
renovate[bot]
74e384531c chore(deps): update pre-commit hook editorconfig-checker/editorconfig-checker.python (3.4.0 → 3.4.1) (#89)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 06:46:18 +00:00
renovate[bot]
923f2d5914 chore(deps)!: update ivuorinen/actions (25.10.25 → v2025.10.26) (#88) 2025-10-27 08:42:56 +02:00
renovate[bot]
5dd8f2507a chore(deps): update ivuorinen/actions action (25.10.24 → 25.10.25) (#87)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 02:32:51 +00:00
renovate[bot]
f32bb1ddaf chore(deps): update github/codeql-action action (v4.30.9 → v4.31.0) (#86)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 05:49:44 +00:00
renovate[bot]
a10b9b6d61 chore(deps): update ivuorinen/actions action (25.10.20 → 25.10.24) (#85)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-25 00:51:43 +00:00
renovate[bot]
a251a26da1 chore(deps): update github/codeql-action action (v4.30.8 → v4.30.9) (#81)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 09:27:51 +03:00
renovate[bot]
a03b132581 chore(deps): update ivuorinen/actions action (25.10.18 → 25.10.20) (#83)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 00:47:00 +00:00
renovate[bot]
4361a0c9e5 chore(deps): update ivuorinen/actions action (25.10.16 → 25.10.18) (#82)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-19 04:53:57 +00:00
renovate[bot]
74e92bc4a0 chore(deps): update ivuorinen/actions action (25.10.15 → 25.10.16) (#80)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 00:43:51 +00:00
renovate[bot]
df3d8cbbd4 chore(deps): update securego/gosec action (v2.22.9 → v2.22.10) (#79)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 04:29:12 +00:00
renovate[bot]
3068992e2b chore(deps): update ivuorinen/actions action (25.10.14 → 25.10.15) (#78)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 02:09:17 +00:00
renovate[bot]
dd85c7a98d chore(deps): update image golang to v1.25.3 (#77)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 05:57:15 +00:00
renovate[bot]
11387f6c97 chore(deps): update ivuorinen/actions action (25.10.12 → 25.10.14) (#76)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-15 01:02:38 +00:00
renovate[bot]
7ea2d597cf chore(deps): update ivuorinen/actions action (25.10.7 → 25.10.12) (#75)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 05:28:50 +00:00
renovate[bot]
743746f7ef chore(deps): update image golang to v1.25.2 (#74)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-12 05:42:16 +00:00
renovate[bot]
4b71bc5409 chore(deps): update github/codeql-action action (v4.30.7 → v4.30.8) (#71)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-11 01:20:33 +03:00
renovate[bot]
eef03a3556 chore(deps): update image alpine to v3.22.2 (#73)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 12:19:23 +03:00
3f65b813bd feat: update go to 1.25, add permissions and envs (#49)
* chore(ci): update go to 1.25, add permissions and envs
* fix(ci): update pr-lint.yml
* chore: update go, fix linting
* fix: tests and linting
* fix(lint): lint fixes, renovate should now pass
* fix: updates, security upgrades
* chore: workflow updates, lint
* fix: more lint, checkmake, and other fixes
* fix: more lint, convert scripts to POSIX compliant
* fix: simplify codeql workflow
* tests: increase test coverage, fix found issues
* fix(lint): editorconfig checking, add to linters
* fix(lint): shellcheck, add to linters
* fix(lint): apply cr comment suggestions
* fix(ci): remove step-security/harden-runner
* fix(lint): remove duplication, apply cr fixes
* fix(ci): tests in CI/CD pipeline
* chore(lint): deduplication of strings
* fix(lint): apply cr comment suggestions
* fix(ci): actionlint
* fix(lint): apply cr comment suggestions
* chore: lint, add deps management
2025-10-10 12:14:42 +03:00
renovate[bot]
958f5952a0 chore(deps): update ivuorinen/actions action (25.10.6 → 25.10.7) (#72)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 21:02:29 +00:00
renovate[bot]
733ba1ed4f chore(deps): update ivuorinen/actions action (25.10.1 → 25.10.6) (#70)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 05:01:22 +00:00
renovate[bot]
350106989c chore(deps): update ivuorinen/actions action (25.9.30 → 25.10.1) (#68)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 22:31:06 +00:00
renovate[bot]
ffe17a6b1e chore(deps): update ivuorinen/actions action (25.9.29 → 25.9.30) (#66)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-01 20:32:22 +00:00
renovate[bot]
395e6aa3de chore(deps): update ivuorinen/actions action (25.9.21 → 25.9.29) (#65)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-30 01:25:33 +00:00
renovate[bot]
86e4234a97 chore(deps): update securego/gosec action (v2.22.8 → v2.22.9) (#64)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-24 09:43:31 +03:00
renovate[bot]
860a6eed18 chore(deps): update pre-commit hook golangci/golangci-lint (v2.4.0 → v2.5.0) (#63)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 10:43:08 +00:00
renovate[bot]
9ceac04762 chore(deps): update ivuorinen/actions action (25.9.19 → 25.9.21) (#62)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 06:06:27 +00:00
renovate[bot]
1dfefac52b chore(deps): update ivuorinen/actions action (25.9.17 → 25.9.19) (#61)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-20 04:58:55 +00:00
renovate[bot]
c182a91326 chore(deps)!: update golangci/golangci-lint (v1.64.8 → v2.4.0) (#48) 2025-09-19 22:21:33 +03:00
renovate[bot]
bae69a7774 chore(deps): update ivuorinen/actions action (25.9.16 → 25.9.17) (#60)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-19 01:11:25 +00:00
renovate[bot]
3b25b5f680 chore(deps): update step-security/harden-runner action (v2.13.0 → v2.13.1) (#58)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-18 11:53:45 +03:00
renovate[bot]
823fdb4e27 chore(deps): update ivuorinen/actions action (25.9.8 → 25.9.16) (#59)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-17 05:54:40 +00:00
renovate[bot]
bc28f837b3 fix(deps): update module github.com/spf13/viper (v1.20.1 → v1.21.0) (#57)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 09:01:16 +00:00
renovate[bot]
608f8c1b6f chore(deps): update ivuorinen/actions action (25.8.31 → 25.9.8) (#56)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 05:50:34 +00:00
renovate[bot]
259669b3d9 chore(deps): update image alpine to v3.22.1 (#26)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 17:08:32 +03:00
renovate[bot]
428e9cb977 fix(deps): update module github.com/spf13/viper (v1.20.0 → v1.20.1) (#25)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 17:07:11 +03:00
renovate[bot]
9e3d1292df chore(deps): update pre-commit hook golangci/golangci-lint (v1.57.2 → v1.64.8) (#47)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 17:06:01 +03:00
renovate[bot]
8244ee6ea4 chore(deps): update module github.com/go-viper/mapstructure/v2 (v2.2.1 → v2.4.0) [security] (#24)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-05 01:03:12 +03:00
renovate[bot]
d342bd2a0a chore(deps)!: update actions/setup-go (#55) 2025-09-05 00:56:30 +03:00
renovate[bot]
df93c4b118 chore(deps): update ivuorinen/actions action (25.8.25 → 25.8.31) (#54)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-02 00:34:59 +00:00
renovate[bot]
a61b1a4f57 chore(deps): pin securego/gosec action (v2.22.8 → ) (#52) 2025-09-01 21:54:37 +03:00
renovate[bot]
6ff435da9d chore(deps): update ivuorinen/actions action (25.8.21 → 25.8.25) (#53)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-26 21:36:16 +00:00
d0f789e823 fix(ci): use securego/gosec in security.yml 2025-08-25 19:17:04 +03:00
renovate[bot]
37210fa8d4 feat(github-action)!: update actions/checkout (#42)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 12:21:23 +03:00
renovate[bot]
f2e9a2e0d8 fix(github-action): update ivuorinen/actions (25.8.18 → 25.8.21) (#46)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 02:32:27 +00:00
renovate[bot]
4e4f617d95 feat(github-action)!: Update actions/download-artifact (v4.3.0 → v5.0.0) (#41)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 15:29:48 +03:00
renovate[bot]
02b68b12d7 feat(github-action): update actions/checkout (v4.2.2 → v4.3.0) (#43)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 10:09:13 +03:00
renovate[bot]
f4ecb678c6 fix(github-action): update ivuorinen/actions (25.8.11 → 25.8.18) (#45)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 06:07:16 +00:00
renovate[bot]
3477400602 chore(deps): pin dependencies (#38) 2025-08-14 02:33:45 +03:00
renovate[bot]
87d0a78d38 fix(github-action): update ivuorinen/actions (25.8.4 → 25.8.11) (#44)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 04:12:56 +00:00
renovate[bot]
dd84267f37 feat(github-action): update ivuorinen/actions (25.7.28 → 25.8.4) (#40)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 02:36:26 +00:00
eef3ab3761 chore: tweaks, simplification, tests 2025-07-30 19:01:59 +03:00
b369d317b1 feat(security): improve security features, fixes 2025-07-29 13:55:25 +03:00
e35126856d feat: many features, check TODO.md 2025-07-29 13:55:25 +03:00
renovate[bot]
3556b06bb9 fix(github-action): update ivuorinen/actions (25.7.21 → 25.7.28) (#37)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-29 04:11:05 +00:00
renovate[bot]
7c738b75de feat(github-action): update docker/setup-buildx-action (v3.10.0 → v3.11.1) (#33)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 16:23:42 +03:00
renovate[bot]
460f90c03f feat(github-action): update actions/setup-go (v5.4.0 → v5.5.0) (#32)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 16:06:58 +03:00
renovate[bot]
4c0f17e53d chore(deps): pin actions/checkout action to (#36)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-23 15:03:45 +03:00
1e4869b79c fix(ci): remove "arm" from build targets in build-test-publish 2025-07-23 13:30:32 +03:00
166e69fc63 fix(ci): add checkout to pr-lint with creds 2025-07-22 12:50:48 +03:00
renovate[bot]
89d8fc3f51 feat(github-action): update step-security/harden-runner (v2.11.0 → v2.13.0) (#34)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 09:21:01 +03:00
renovate[bot]
3619a59b3c fix(github-action): update ivuorinen/actions (25.7.14 → 25.7.21) (#35)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 05:27:20 +00:00
renovate[bot]
ac7d7e3790 feat(github-action): update actions/download-artifact (v4.2.1 → v4.3.0) (#31)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 15:53:53 +00:00
renovate[bot]
b13b9da7dd fix(github-action): update ivuorinen/actions (25.7.7 → 25.7.14) (#30)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 04:58:31 +00:00
1d2b68f059 chore(lint): replace docker-based precommit (#29) 2025-07-14 16:48:43 +03:00
c91bfa0ccf feat(ci): simplify workflows, fix renovate.json (#20)
* feat(ci): simplify workflows, fix renovate.json
* fix(ci): replace sarif parsing
* fix(ci): lint fixes, json to sarif
* chore(ci): remove sarif stuff for now
2025-07-14 01:57:48 +03:00
9a2bbda223 test: fix linter package names (#23)
* test: fix linter package names

* chore: pr-lint.yml
2025-07-14 01:48:39 +03:00
renovate[bot]
70fede7635 fix(github-action): update actions/upload-artifact (v4.6.0 → v4.6.2) (#28)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 19:50:09 +00:00
renovate[bot]
376dd21a8b feat(github-action): update ivuorinen/actions (25.3.19 → 25.7.7) (#27)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-13 12:52:06 +00:00
72592fb559 fix: renovate.json 2025-07-13 14:18:36 +03:00
b017814c6d test: check cleanup errors (#22) 2025-07-13 14:08:23 +03:00
ef2296d45e docs: add AGENTS usage guidance (#21) 2025-07-11 18:33:56 +03:00
d752b6d271 fix(lint): linting problems 2025-03-23 22:26:02 +02:00
4b8d66c778 feat(tests): more tests and ci action (#14)
* feat(tests): more tests and ci action
* fix(ci): coverage and pr-lint
* fix(ci): renovate rules, permissions, linting, actions
* fix(lint): editorconfig fixes
* fix(lint): kics.config
* fix(lint): formatting, permissions, pre-commit config
* chore(ci): set workflow to use go 1.23, go mod tidy
* chore(ci): fixes and stuff
* chore(ci): disable GO_GOLANGCI_LINT
* chore(ci): pinning, permissions
2025-03-23 19:41:39 +02:00
2aa2a94a38 chore(deps): update go version 2025-03-17 16:11:59 +02:00
renovate[bot]
48fa5ca422 fix(deps): update module github.com/spf13/viper to v1.20.0 (#18)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 12:19:25 +02:00
renovate[bot]
0b31398443 feat(github-action): update go (1.23.7 → 1.24.1) (#16)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 13:30:32 +02:00
renovate[bot]
d807e6d659 chore(deps): update module golang.org/x/net to v0.36.0 [security] (#17)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-14 10:23:42 +02:00
8a638f0f43 fix(ci): add permissions to ci.yml 2025-03-03 10:53:51 +02:00
renovate[bot]
87855dcbf9 chore(deps): pin dependencies (#15)
Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-09 17:49:25 +02:00
149 changed files with 29755 additions and 691 deletions

8
.checkmake Normal file
View 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
View 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

View File

@@ -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

View 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
}

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* ivuorinen

16
.github/actions/setup/action.yml vendored Normal file
View 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@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
with:
go-version-file: ".go-version"
cache: true

View File

@@ -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
View 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.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 .

View File

@@ -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 }} .

34
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
---
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: "CodeQL"
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "30 1 * * 0"
merge_group:
permissions: {}
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["actions", "go"]
steps:
- name: CodeQL Analysis
uses: ivuorinen/actions/codeql-analysis@1da3a0e79fcd7da6bed9ee1979f1449ba11f58f9 # v2026.03.14
with:
language: ${{ matrix.language }}
queries: security-and-quality

32
.github/workflows/pr-lint.yml vendored Normal file
View 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@1da3a0e79fcd7da6bed9ee1979f1449ba11f58f9 # v2026.03.14

97
.github/workflows/security.yml vendored Normal file
View 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@bb17e422fc34bf4c0a2e5cab9d07dc45a68c040c # v2.24.7
with:
args: "-fmt sarif -out gosec-results.sarif ./..."
- name: Upload gosec results to GitHub Security tab
uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.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
View 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@1da3a0e79fcd7da6bed9ee1979f1449ba11f58f9 # v2026.03.14

12
.gitignore vendored
View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
1.26.1

25
.mega-linter.yml Normal file
View 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
View 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.1
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
View File

@@ -0,0 +1 @@
/cache

74
.serena/project.yml Normal file
View 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
View 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
View 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
View 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
View 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

View File

@@ -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
View File

@@ -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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

59
cli/processor_types.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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.

View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
}

View File

@@ -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
View 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
```

View 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
View 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")
}
}

View 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
View 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()
}

View File

@@ -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)
}

View File

@@ -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
View 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
View 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
View 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",
}
}

View File

@@ -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
View 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)
}

View 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)
}
}
}

View 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()
}

View 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)
}
})
}
}

View 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)
}
})
}

View 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
View 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
View 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
View 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
View 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)
})
}

View File

@@ -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
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
}

View File

@@ -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
View 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
}

View 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) // #nosec G118 - cancel returned to caller
}
// 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) // #nosec G118 - cancel returned to caller
}

View 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")
}
}

View 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()
}

View 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")
}
}

View 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")
}
}

View 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
}
}
}
}

View 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)
}
}

View 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()
}
}

View 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
}

View 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()
}

View 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,
)
}

View 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)
}
}

View File

@@ -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
}
}
// 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 {
if w.filter.shouldSkipEntry(entry, fullPath, rules) {
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
View 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])
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}(outFile.Name())
},
)
}
}
writeCh := make(chan WriteRequest)
done := make(chan struct{})
// 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)
}
if removeErr := os.Remove(outFile.Name()); removeErr != nil {
t.Errorf("remove temp file: %v", removeErr)
}
}()
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
View 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
View File

@@ -1,33 +1,34 @@
module github.com/ivuorinen/gibidify
go 1.23
go 1.25.0
toolchain go1.26.1
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.35.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
View File

@@ -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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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=

View File

@@ -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")
}
}

Some files were not shown because too many files have changed in this diff Show More