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
This commit is contained in:
2025-12-10 19:07:11 +02:00
committed by GitHub
parent ea4a39a360
commit 95b7ef6dd3
149 changed files with 22990 additions and 8976 deletions

803
shared/constants.go Normal file
View File

@@ -0,0 +1,803 @@
// Package shared provides common constants used across the gibidify application.
package shared
// Byte Conversion Constants
const (
// BytesPerKB is the number of bytes in a kilobyte (1024).
BytesPerKB = 1024
// BytesPerMB is the number of bytes in a megabyte (1024 * 1024).
BytesPerMB = 1024 * BytesPerKB
// BytesPerGB is the number of bytes in a gigabyte (1024 * 1024 * 1024).
BytesPerGB = 1024 * BytesPerMB
)
// Configuration Default Values - Numeric Constants
const (
// ConfigFileSizeLimitDefault is the default maximum file size (5MB).
ConfigFileSizeLimitDefault = 5 * BytesPerMB
// ConfigFileSizeLimitMin is the minimum allowed file size limit (1KB).
ConfigFileSizeLimitMin = BytesPerKB
// ConfigFileSizeLimitMax is the maximum allowed file size limit (100MB).
ConfigFileSizeLimitMax = 100 * BytesPerMB
// ConfigMaxFilesDefault is the default maximum number of files to process.
ConfigMaxFilesDefault = 10000
// ConfigMaxFilesMin is the minimum allowed file count limit.
ConfigMaxFilesMin = 1
// ConfigMaxFilesMax is the maximum allowed file count limit.
ConfigMaxFilesMax = 1000000
// ConfigMaxTotalSizeDefault is the default maximum total size of files (1GB).
ConfigMaxTotalSizeDefault = BytesPerGB
// ConfigMaxTotalSizeMin is the minimum allowed total size limit (1MB).
ConfigMaxTotalSizeMin = BytesPerMB
// ConfigMaxTotalSizeMax is the maximum allowed total size limit (100GB).
ConfigMaxTotalSizeMax = 100 * BytesPerGB
// ConfigFileProcessingTimeoutSecDefault is the default timeout for individual file processing (30 seconds).
ConfigFileProcessingTimeoutSecDefault = 30
// ConfigFileProcessingTimeoutSecMin is the minimum allowed file processing timeout (1 second).
ConfigFileProcessingTimeoutSecMin = 1
// ConfigFileProcessingTimeoutSecMax is the maximum allowed file processing timeout (300 seconds).
ConfigFileProcessingTimeoutSecMax = 300
// ConfigOverallTimeoutSecDefault is the default timeout for overall processing (3600 seconds = 1 hour).
ConfigOverallTimeoutSecDefault = 3600
// ConfigOverallTimeoutSecMin is the minimum allowed overall timeout (10 seconds).
ConfigOverallTimeoutSecMin = 10
// ConfigOverallTimeoutSecMax is the maximum allowed overall timeout (86400 seconds = 24 hours).
ConfigOverallTimeoutSecMax = 86400
// ConfigMaxConcurrentReadsDefault is the default maximum concurrent file reading operations.
ConfigMaxConcurrentReadsDefault = 10
// ConfigMaxConcurrentReadsMin is the minimum allowed concurrent reads.
ConfigMaxConcurrentReadsMin = 1
// ConfigMaxConcurrentReadsMax is the maximum allowed concurrent reads.
ConfigMaxConcurrentReadsMax = 100
// ConfigRateLimitFilesPerSecDefault is the default rate limit for file processing (0 = disabled).
ConfigRateLimitFilesPerSecDefault = 0
// ConfigRateLimitFilesPerSecMin is the minimum rate limit.
ConfigRateLimitFilesPerSecMin = 0
// ConfigRateLimitFilesPerSecMax is the maximum rate limit.
ConfigRateLimitFilesPerSecMax = 10000
// ConfigHardMemoryLimitMBDefault is the default hard memory limit (512MB).
ConfigHardMemoryLimitMBDefault = 512
// ConfigHardMemoryLimitMBMin is the minimum hard memory limit (64MB).
ConfigHardMemoryLimitMBMin = 64
// ConfigHardMemoryLimitMBMax is the maximum hard memory limit (8192MB = 8GB).
ConfigHardMemoryLimitMBMax = 8192
// ConfigMaxPendingFilesDefault is the default maximum files in file channel buffer.
ConfigMaxPendingFilesDefault = 1000
// ConfigMaxPendingWritesDefault is the default maximum writes in write channel buffer.
ConfigMaxPendingWritesDefault = 100
// ConfigMaxMemoryUsageDefault is the default maximum memory usage (100MB).
ConfigMaxMemoryUsageDefault = 100 * BytesPerMB
// ConfigMemoryCheckIntervalDefault is the default memory check interval (every 1000 files).
ConfigMemoryCheckIntervalDefault = 1000
// ConfigMaxConcurrencyDefault is the default maximum concurrency (high enough for typical systems).
ConfigMaxConcurrencyDefault = 32
// FileTypeRegistryMaxCacheSize is the default maximum cache size for file type registry.
FileTypeRegistryMaxCacheSize = 500
// ConfigMarkdownHeaderLevelDefault is the default header level for file sections.
ConfigMarkdownHeaderLevelDefault = 0
// ConfigMarkdownMaxLineLengthDefault is the default maximum line length (0 = unlimited).
ConfigMarkdownMaxLineLengthDefault = 0
)
// Configuration Default Values - Boolean Constants
const (
// ConfigFileTypesEnabledDefault is the default state for file type detection.
ConfigFileTypesEnabledDefault = true
// ConfigBackpressureEnabledDefault is the default state for backpressure.
ConfigBackpressureEnabledDefault = true
// ConfigResourceLimitsEnabledDefault is the default state for resource limits.
ConfigResourceLimitsEnabledDefault = true
// ConfigEnableGracefulDegradationDefault is the default state for graceful degradation.
ConfigEnableGracefulDegradationDefault = true
// ConfigEnableResourceMonitoringDefault is the default state for resource monitoring.
ConfigEnableResourceMonitoringDefault = true
// ConfigMetadataIncludeStatsDefault is the default for including stats in metadata.
ConfigMetadataIncludeStatsDefault = false
// ConfigMetadataIncludeTimestampDefault is the default for including timestamp.
ConfigMetadataIncludeTimestampDefault = false
// ConfigMetadataIncludeFileCountDefault is the default for including file count.
ConfigMetadataIncludeFileCountDefault = false
// ConfigMetadataIncludeSourcePathDefault is the default for including source path.
ConfigMetadataIncludeSourcePathDefault = false
// ConfigMetadataIncludeFileTypesDefault is the default for including file types.
ConfigMetadataIncludeFileTypesDefault = false
// ConfigMetadataIncludeProcessingTimeDefault is the default for including processing time.
ConfigMetadataIncludeProcessingTimeDefault = false
// ConfigMetadataIncludeTotalSizeDefault is the default for including total size.
ConfigMetadataIncludeTotalSizeDefault = false
// ConfigMetadataIncludeMetricsDefault is the default for including metrics.
ConfigMetadataIncludeMetricsDefault = false
// ConfigMarkdownUseCodeBlocksDefault is the default for using code blocks.
ConfigMarkdownUseCodeBlocksDefault = false
// ConfigMarkdownIncludeLanguageDefault is the default for including language in code blocks.
ConfigMarkdownIncludeLanguageDefault = false
// ConfigMarkdownTableOfContentsDefault is the default for table of contents.
ConfigMarkdownTableOfContentsDefault = false
// ConfigMarkdownUseCollapsibleDefault is the default for collapsible sections.
ConfigMarkdownUseCollapsibleDefault = false
// ConfigMarkdownSyntaxHighlightingDefault is the default for syntax highlighting.
ConfigMarkdownSyntaxHighlightingDefault = false
// ConfigMarkdownLineNumbersDefault is the default for line numbers.
ConfigMarkdownLineNumbersDefault = false
// ConfigMarkdownFoldLongFilesDefault is the default for folding long files.
ConfigMarkdownFoldLongFilesDefault = false
)
// Configuration Default Values - String Constants
const (
// ConfigOutputTemplateDefault is the default output template (empty = use built-in).
ConfigOutputTemplateDefault = ""
// ConfigMarkdownCustomCSSDefault is the default custom CSS.
ConfigMarkdownCustomCSSDefault = ""
// ConfigCustomHeaderDefault is the default custom header template.
ConfigCustomHeaderDefault = ""
// ConfigCustomFooterDefault is the default custom footer template.
ConfigCustomFooterDefault = ""
// ConfigCustomFileHeaderDefault is the default custom file header template.
ConfigCustomFileHeaderDefault = ""
// ConfigCustomFileFooterDefault is the default custom file footer template.
ConfigCustomFileFooterDefault = ""
)
// Configuration Keys - Viper Path Constants
const (
// ConfigKeyFileSizeLimit is the config key for file size limit.
ConfigKeyFileSizeLimit = "fileSizeLimit"
// ConfigKeyMaxConcurrency is the config key for max concurrency.
ConfigKeyMaxConcurrency = "maxConcurrency"
// ConfigKeySupportedFormats is the config key for supported formats.
ConfigKeySupportedFormats = "supportedFormats"
// ConfigKeyFilePatterns is the config key for file patterns.
ConfigKeyFilePatterns = "filePatterns"
// ConfigKeyIgnoreDirectories is the config key for ignored directories.
ConfigKeyIgnoreDirectories = "ignoreDirectories"
// ConfigKeyFileTypesEnabled is the config key for fileTypes.enabled.
ConfigKeyFileTypesEnabled = "fileTypes.enabled"
// ConfigKeyFileTypesCustomImageExtensions is the config key for fileTypes.customImageExtensions.
ConfigKeyFileTypesCustomImageExtensions = "fileTypes.customImageExtensions"
// ConfigKeyFileTypesCustomBinaryExtensions is the config key for fileTypes.customBinaryExtensions.
ConfigKeyFileTypesCustomBinaryExtensions = "fileTypes.customBinaryExtensions"
// ConfigKeyFileTypesCustomLanguages is the config key for fileTypes.customLanguages.
ConfigKeyFileTypesCustomLanguages = "fileTypes.customLanguages"
// ConfigKeyFileTypesDisabledImageExtensions is the config key for fileTypes.disabledImageExtensions.
ConfigKeyFileTypesDisabledImageExtensions = "fileTypes.disabledImageExtensions"
// ConfigKeyFileTypesDisabledBinaryExtensions is the config key for fileTypes.disabledBinaryExtensions.
ConfigKeyFileTypesDisabledBinaryExtensions = "fileTypes.disabledBinaryExtensions"
// ConfigKeyFileTypesDisabledLanguageExts is the config key for fileTypes.disabledLanguageExtensions.
ConfigKeyFileTypesDisabledLanguageExts = "fileTypes.disabledLanguageExtensions"
// ConfigKeyBackpressureEnabled is the config key for backpressure.enabled.
ConfigKeyBackpressureEnabled = "backpressure.enabled"
// ConfigKeyBackpressureMaxPendingFiles is the config key for backpressure.maxPendingFiles.
ConfigKeyBackpressureMaxPendingFiles = "backpressure.maxPendingFiles"
// ConfigKeyBackpressureMaxPendingWrites is the config key for backpressure.maxPendingWrites.
ConfigKeyBackpressureMaxPendingWrites = "backpressure.maxPendingWrites"
// ConfigKeyBackpressureMaxMemoryUsage is the config key for backpressure.maxMemoryUsage.
ConfigKeyBackpressureMaxMemoryUsage = "backpressure.maxMemoryUsage"
// ConfigKeyBackpressureMemoryCheckInt is the config key for backpressure.memoryCheckInterval.
ConfigKeyBackpressureMemoryCheckInt = "backpressure.memoryCheckInterval"
// ConfigKeyResourceLimitsEnabled is the config key for resourceLimits.enabled.
ConfigKeyResourceLimitsEnabled = "resourceLimits.enabled"
// ConfigKeyResourceLimitsMaxFiles is the config key for resourceLimits.maxFiles.
ConfigKeyResourceLimitsMaxFiles = "resourceLimits.maxFiles"
// ConfigKeyResourceLimitsMaxTotalSize is the config key for resourceLimits.maxTotalSize.
ConfigKeyResourceLimitsMaxTotalSize = "resourceLimits.maxTotalSize"
// ConfigKeyResourceLimitsFileProcessingTO is the config key for resourceLimits.fileProcessingTimeoutSec.
ConfigKeyResourceLimitsFileProcessingTO = "resourceLimits.fileProcessingTimeoutSec"
// ConfigKeyResourceLimitsOverallTO is the config key for resourceLimits.overallTimeoutSec.
ConfigKeyResourceLimitsOverallTO = "resourceLimits.overallTimeoutSec"
// ConfigKeyResourceLimitsMaxConcurrentReads is the config key for resourceLimits.maxConcurrentReads.
ConfigKeyResourceLimitsMaxConcurrentReads = "resourceLimits.maxConcurrentReads"
// ConfigKeyResourceLimitsRateLimitFilesPerSec is the config key for resourceLimits.rateLimitFilesPerSec.
ConfigKeyResourceLimitsRateLimitFilesPerSec = "resourceLimits.rateLimitFilesPerSec"
// ConfigKeyResourceLimitsHardMemoryLimitMB is the config key for resourceLimits.hardMemoryLimitMB.
ConfigKeyResourceLimitsHardMemoryLimitMB = "resourceLimits.hardMemoryLimitMB"
// ConfigKeyResourceLimitsEnableGracefulDeg is the config key for resourceLimits.enableGracefulDegradation.
ConfigKeyResourceLimitsEnableGracefulDeg = "resourceLimits.enableGracefulDegradation"
// ConfigKeyResourceLimitsEnableMonitoring is the config key for resourceLimits.enableResourceMonitoring.
ConfigKeyResourceLimitsEnableMonitoring = "resourceLimits.enableResourceMonitoring"
// ConfigKeyOutputTemplate is the config key for output.template.
ConfigKeyOutputTemplate = "output.template"
// ConfigKeyOutputMarkdownHeaderLevel is the config key for output.markdown.headerLevel.
ConfigKeyOutputMarkdownHeaderLevel = "output.markdown.headerLevel"
// ConfigKeyOutputMarkdownMaxLineLen is the config key for output.markdown.maxLineLength.
ConfigKeyOutputMarkdownMaxLineLen = "output.markdown.maxLineLength"
// ConfigKeyOutputMarkdownCustomCSS is the config key for output.markdown.customCSS.
ConfigKeyOutputMarkdownCustomCSS = "output.markdown.customCSS"
// ConfigKeyOutputCustomHeader is the config key for output.custom.header.
ConfigKeyOutputCustomHeader = "output.custom.header"
// ConfigKeyOutputCustomFooter is the config key for output.custom.footer.
ConfigKeyOutputCustomFooter = "output.custom.footer"
// ConfigKeyOutputCustomFileHeader is the config key for output.custom.fileHeader.
ConfigKeyOutputCustomFileHeader = "output.custom.fileHeader"
// ConfigKeyOutputCustomFileFooter is the config key for output.custom.fileFooter.
ConfigKeyOutputCustomFileFooter = "output.custom.fileFooter"
// ConfigKeyOutputVariables is the config key for output.variables.
ConfigKeyOutputVariables = "output.variables"
)
// Configuration Collections - Slice and Map Variables
var (
// ConfigIgnoredDirectoriesDefault is the default list of directories to ignore.
ConfigIgnoredDirectoriesDefault = []string{
"vendor", "node_modules", ".git", "dist", "build", "target",
"bower_components", "cache", "tmp",
}
// ConfigCustomImageExtensionsDefault is the default list of custom image extensions.
ConfigCustomImageExtensionsDefault = []string{}
// ConfigCustomBinaryExtensionsDefault is the default list of custom binary extensions.
ConfigCustomBinaryExtensionsDefault = []string{}
// ConfigDisabledImageExtensionsDefault is the default list of disabled image extensions.
ConfigDisabledImageExtensionsDefault = []string{}
// ConfigDisabledBinaryExtensionsDefault is the default list of disabled binary extensions.
ConfigDisabledBinaryExtensionsDefault = []string{}
// ConfigDisabledLanguageExtensionsDefault is the default list of disabled language extensions.
ConfigDisabledLanguageExtensionsDefault = []string{}
// ConfigCustomLanguagesDefault is the default custom language mappings.
ConfigCustomLanguagesDefault = map[string]string{}
// ConfigTemplateVariablesDefault is the default template variables.
ConfigTemplateVariablesDefault = map[string]string{}
// ConfigSupportedFormatsDefault is the default list of supported output formats.
ConfigSupportedFormatsDefault = []string{"json", "yaml", "markdown"}
// ConfigFilePatternsDefault is the default list of file patterns (empty = all files).
ConfigFilePatternsDefault = []string{}
)
// Test Paths and Files
const (
// TestSourcePath is a common test source directory path.
TestSourcePath = "/test/source"
// TestOutputMarkdown is a common test output markdown file path.
TestOutputMarkdown = "/test/output.md"
// TestFile1 is a common test filename.
TestFile1 = "file1.txt"
// TestFile2 is a common test filename.
TestFile2 = "file2.txt"
// TestOutputMD is a common output markdown filename.
TestOutputMD = "output.md"
// TestMD is a common markdown test file.
TestMD = "test.md"
// TestFile1Name is test1.txt used in benchmark tests.
TestFile1Name = "test1.txt"
// TestFile2Name is test2.txt used in benchmark tests.
TestFile2Name = "test2.txt"
// TestFile3Name is test3.md used in benchmark tests.
TestFile3Name = "test3.md"
// TestFile1Go is a common Go test file path.
TestFile1Go = "/test/file.go"
// TestFile1GoAlt is an alternative Go test file path.
TestFile1GoAlt = "/test/file1.go"
// TestFile2JS is a common JavaScript test file path.
TestFile2JS = "/test/file2.js"
// TestErrorPy is a Python test file path for error scenarios.
TestErrorPy = "/test/error.py"
// TestNetworkData is a network data file path for testing.
TestNetworkData = "/tmp/network.data"
)
// Test CLI Flags
const (
// TestCLIFlagSource is the -source flag.
TestCLIFlagSource = "-source"
// TestCLIFlagDestination is the -destination flag.
TestCLIFlagDestination = "-destination"
// TestCLIFlagFormat is the -format flag.
TestCLIFlagFormat = "-format"
// TestCLIFlagNoUI is the -no-ui flag.
TestCLIFlagNoUI = "-no-ui"
// TestCLIFlagConcurrency is the -concurrency flag.
TestCLIFlagConcurrency = "-concurrency"
)
// Test Content Strings
const (
// TestContent is common test file content.
TestContent = "Hello World"
// TestConcurrencyList is a common concurrency list for benchmarks.
TestConcurrencyList = "1,2,4,8"
// TestFormatList is a common format list for tests.
TestFormatList = "json,yaml,markdown"
// TestSharedGoContent is content for shared.go test files.
TestSharedGoContent = "package main\n\nfunc Helper() {}"
// TestSafeConversion is used in safe conversion tests.
TestSafeConversion = "safe conversion"
// TestContentTest is generic test content string.
TestContentTest = "test content"
// TestContentEmpty is empty content test string.
TestContentEmpty = "empty content"
// TestContentHelloWorld is hello world test string.
TestContentHelloWorld = "hello world"
// TestContentDocumentation is documentation test string.
TestContentDocumentation = "# Documentation"
// TestContentPackageHandlers is package handlers test string.
TestContentPackageHandlers = "package handlers"
)
// Test Error Messages
const (
// TestMsgExpectedError is used when an error was expected but none occurred.
TestMsgExpectedError = "Expected error but got none"
// TestMsgErrorShouldContain is used to check if error message contains expected text.
TestMsgErrorShouldContain = "Error should contain %q, got: %v"
// TestMsgUnexpectedError is used when an unexpected error occurred.
TestMsgUnexpectedError = "Unexpected error: %v"
// TestMsgFailedToClose is used for file close failures.
TestMsgFailedToClose = "Failed to close pipe writer: %v"
// TestMsgFailedToCreateFile is used for file creation failures.
TestMsgFailedToCreateFile = "Failed to create temp file: %v"
// TestMsgFailedToRemoveTempFile is used for temp file removal failures.
TestMsgFailedToRemoveTempFile = "Failed to remove temp file: %v"
// TestMsgFailedToReadOutput is used for output read failures.
TestMsgFailedToReadOutput = "Failed to read captured output: %v"
// TestMsgFailedToCreateTempDir is used for temp directory creation failures.
TestMsgFailedToCreateTempDir = "Failed to create temp dir: %v"
// TestMsgOutputMissingSubstring is used when output doesn't contain expected text.
TestMsgOutputMissingSubstring = "Output missing expected substring: %q\nFull output:\n%s"
// TestMsgOperationFailed is used when an operation fails.
TestMsgOperationFailed = "Operation %s failed: %v"
// TestMsgOperationNoError is used when an operation expected error but got none.
TestMsgOperationNoError = "Operation %s expected error but got none"
// TestMsgTimeoutWriterCompletion is used for writer timeout errors.
TestMsgTimeoutWriterCompletion = "timeout waiting for writer completion (doneCh)"
// TestMsgFailedToCreateTestDir is used for test directory creation failures.
TestMsgFailedToCreateTestDir = "Failed to create test directory: %v"
// TestMsgFailedToCreateTestFile is used for test file creation failures.
TestMsgFailedToCreateTestFile = "Failed to create test file: %v"
// TestMsgNewEngineFailed is used when template engine creation fails.
TestMsgNewEngineFailed = "NewEngine failed: %v"
// TestMsgRenderFileContentFailed is used when rendering file content fails.
TestMsgRenderFileContentFailed = "RenderFileContent failed: %v"
// TestMsgFailedToCreatePipe is used for pipe creation failures.
TestMsgFailedToCreatePipe = "Failed to create pipe: %v"
// TestMsgFailedToWriteContent is used for content write failures.
TestMsgFailedToWriteContent = "Failed to write content: %v"
// TestMsgFailedToCloseFile is used for file close failures.
TestMsgFailedToCloseFile = "Failed to close temp file: %v"
// TestFileStreamTest is a stream test filename.
TestFileStreamTest = "stream_test.txt"
)
// Test UI Strings
const (
// TestSuggestionsPlain is the plain suggestions header without emoji.
TestSuggestionsPlain = "Suggestions:"
// TestSuggestionsWarning is the warning-style suggestions header.
TestSuggestionsWarning = "⚠ Suggestions:"
// TestSuggestionsIcon is the icon-style suggestions header.
TestSuggestionsIcon = "💡 Suggestions:"
// TestOutputErrorMarker is the error output marker.
TestOutputErrorMarker = "❌ Error:"
// TestOutputSuccessMarker is the success output marker.
TestOutputSuccessMarker = "✓ Success:"
// TestSuggestCheckPermissions suggests checking file permissions.
TestSuggestCheckPermissions = "Check file/directory permissions"
// TestSuggestCheckArguments suggests checking command line arguments.
TestSuggestCheckArguments = "Check your command line arguments"
// TestSuggestVerifyPath suggests verifying the path.
TestSuggestVerifyPath = "Verify the path is correct"
// TestSuggestCheckExists suggests checking if path exists.
TestSuggestCheckExists = "Check if the path exists:"
// TestSuggestCheckFileExists suggests checking if file/directory exists.
TestSuggestCheckFileExists = "Check if the file/directory exists:"
// TestSuggestUseAbsolutePath suggests using absolute paths.
TestSuggestUseAbsolutePath = "Use an absolute path instead of relative"
)
// Test Error Strings and Categories
const (
// TestErrEmptyFilePath is error message for empty file paths.
TestErrEmptyFilePath = "empty file path"
// TestErrTestErrorMsg is a generic test error message string.
TestErrTestErrorMsg = "test error"
// TestErrSyntaxError is a syntax error message.
TestErrSyntaxError = "syntax error"
// TestErrDiskFull is a disk full error message.
TestErrDiskFull = "disk full"
// TestErrAccessDenied is an access denied error message.
TestErrAccessDenied = "access denied"
// TestErrProcessingFailed is a processing failed error message.
TestErrProcessingFailed = "processing failed"
// TestErrCannotAccessFile is an error message for file access errors.
TestErrCannotAccessFile = "cannot access file"
)
// Test Terminal and UI Strings
const (
// TestTerminalXterm256 is a common terminal type for testing.
TestTerminalXterm256 = "xterm-256color"
// TestProgressMessage is a common progress message.
TestProgressMessage = "Processing files"
)
// Test Logger Messages
const (
// TestLoggerDebugMsg is a debug level test message.
TestLoggerDebugMsg = "debug message"
// TestLoggerInfoMsg is an info level test message.
TestLoggerInfoMsg = "info message"
// TestLoggerWarnMsg is a warn level test message.
TestLoggerWarnMsg = "warn message"
)
// Test Assertion Case Names
const (
// TestCaseSuccessCases is the name for success test cases.
TestCaseSuccessCases = "success cases"
// TestCaseEmptyOperationName is the name for empty operation test cases.
TestCaseEmptyOperationName = "empty operation name"
// TestCaseDifferentErrorTypes is the name for different error types test cases.
TestCaseDifferentErrorTypes = "different error types"
// TestCaseFunctionAvailability is the name for function availability test cases.
TestCaseFunctionAvailability = "function availability"
// TestCaseMessageTest is the name for message test cases.
TestCaseMessageTest = "message test"
// TestCaseTestOperation is the name for test operation cases.
TestCaseTestOperation = "test operation"
)
// Test File Extensions and Special Names
const (
// TestExtensionSpecial is a special extension for testing.
TestExtensionSpecial = ".SPECIAL"
// TestExtensionValid is a valid extension for testing custom extensions.
TestExtensionValid = ".valid"
// TestExtensionCustom is a custom extension for testing.
TestExtensionCustom = ".custom"
)
// Test Paths
const (
// TestPathBase is a base test path.
TestPathBase = "/test/path"
// TestPathTestFileGo is a test file.go path.
TestPathTestFileGo = "/test/file.go"
// TestPathTestFileTXT is a test file.txt path.
TestPathTestFileTXT = "/test/file.txt"
// TestPathTestErrorGo is a test error.go path.
TestPathTestErrorGo = "/test/error.go"
// TestPathTestFile1Go is a test file1.go path.
TestPathTestFile1Go = "/test/file1.go"
// TestPathTestFile2JS is a test file2.js path.
TestPathTestFile2JS = "/test/file2.js"
// TestPathTestErrorPy is a test error.py path.
TestPathTestErrorPy = "/test/error.py"
// TestPathTestEmptyTXT is a test empty.txt path.
TestPathTestEmptyTXT = "/test/empty.txt"
// TestPathTestProject is a test project path.
TestPathTestProject = "/test/project"
// TestPathTmpNetworkData is a temp network data path.
TestPathTmpNetworkData = "/tmp/network.data"
// TestPathEtcPasswdTraversal is a path traversal test path.
TestPathEtcPasswdTraversal = "../../../etc/passwd" // #nosec G101 -- test constant, not credentials
)
// Test File Names
const (
// TestFileTXT is a common test file name.
TestFileTXT = "test.txt"
// TestFileGo is a common Go test file name.
TestFileGo = "test.go"
// TestFileSharedGo is a common shared Go file name.
TestFileSharedGo = "shared.go"
// TestFilePNG is a PNG test file name.
TestFilePNG = "test.png"
// TestFileJPG is a JPG test file name.
TestFileJPG = "test.jpg"
// TestFileEXE is an EXE test file name.
TestFileEXE = "test.exe"
// TestFileDLL is a DLL test file name.
TestFileDLL = "test.dll"
// TestFilePy is a Python test file name.
TestFilePy = "test.py"
// TestFileValid is a test file with .valid extension.
TestFileValid = "test.valid"
// TestFileWebP is a WebP test file name.
TestFileWebP = "test.webp"
// TestFileImageJPG is a JPG test file name.
TestFileImageJPG = "image.jpg"
// TestFileBinaryDLL is a DLL test file name.
TestFileBinaryDLL = "binary.dll"
// TestFileScriptPy is a Python script test file name.
TestFileScriptPy = "script.py"
// TestFileMainGo is a main.go test file name.
TestFileMainGo = "main.go"
// TestFileHelperGo is a helper.go test file name.
TestFileHelperGo = "helper.go"
// TestFileJSON is a JSON test file name.
TestFileJSON = "test.json"
// TestFileConfigJSON is a config.json test file name.
TestFileConfigJSON = "config.json"
// TestFileReadmeMD is a README.md test file name.
TestFileReadmeMD = "README.md"
// TestFileOutputTXT is an output.txt test file name.
TestFileOutputTXT = "output.txt"
// TestFileConfigYAML is a config.yaml test file name.
TestFileConfigYAML = "config.yaml"
// TestFileGoExt is a file.go test file name.
TestFileGoExt = "file.go"
)
// Test Validation and Operation Strings
const (
// TestOpParsingFlags is used in error messages for flag parsing operations.
TestOpParsingFlags = "parsing flags"
// TestOpValidatingConcurrency is used for concurrency validation.
TestOpValidatingConcurrency = "validating concurrency"
// TestMsgInvalidConcurrencyLevel is error message for invalid concurrency.
TestMsgInvalidConcurrencyLevel = "invalid concurrency level"
// TestKeyName is a common test key name.
TestKeyName = "test.key"
// TestMsgExpectedExtensionWithoutDot is error message for extension validation.
TestMsgExpectedExtensionWithoutDot = "Expected extension without dot to not work"
// TestMsgSourcePath is the validation message for source path.
TestMsgSourcePath = "source path"
// TestMsgEmptyPath is used for empty path test cases.
TestMsgEmptyPath = "empty path"
// TestMsgPathTraversalAttempt is used for path traversal detection tests.
TestMsgPathTraversalAttempt = "path traversal attempt detected"
// TestCfgResourceLimitsEnabled is the config key for resource limits enabled.
TestCfgResourceLimitsEnabled = "resourceLimits.enabled"
)
// Test Structured Error Format Strings
const (
// TestFmtExpectedFilePath is format string for file path assertions.
TestFmtExpectedFilePath = "Expected FilePath %q, got %q"
// TestFmtExpectedLine is format string for line number assertions.
TestFmtExpectedLine = "Expected Line %d, got %d"
// TestFmtExpectedType is format string for type assertions.
TestFmtExpectedType = "Expected Type %v, got %v"
// TestFmtExpectedCode is format string for code assertions.
TestFmtExpectedCode = "Expected Code %q, got %q"
// TestFmtExpectedMessage is format string for message assertions.
TestFmtExpectedMessage = "Expected Message %q, got %q"
// TestFmtExpectedCount is format string for count assertions.
TestFmtExpectedCount = "Expected %d %s, got %d"
// TestFmtExpectedGot is generic format string for assertions.
TestFmtExpectedGot = "%s returned: %v (type: %T)"
// TestFmtExpectedFilesProcessed is format string for files processed assertion.
TestFmtExpectedFilesProcessed = "Expected files processed > 0, got %d"
// TestFmtExpectedResults is format string for results count assertion.
TestFmtExpectedResults = "Expected %d results, got %d"
// TestFmtExpectedTotalFiles is format string for total files assertion.
TestFmtExpectedTotalFiles = "Expected TotalFiles=1, got %d"
// TestFmtExpectedContent is format string for content assertions.
TestFmtExpectedContent = "Expected content %q, got %q"
// TestFmtExpectedErrorTypeIO is format string for error type IO assertions.
TestFmtExpectedErrorTypeIO = "Expected ErrorTypeIO, got %v"
// TestFmtDirectoryShouldExist is format string for directory existence assertions.
TestFmtDirectoryShouldExist = "Directory %s should exist: %v"
// TestFmtPathShouldBeDirectory is format string for directory type assertions.
TestFmtPathShouldBeDirectory = "Path %s should be a directory"
)
// CLI Error Messages
const (
// CLIMsgErrorFormat is the error message format.
CLIMsgErrorFormat = "Error: %s"
// CLIMsgSuggestions is the suggestions header.
CLIMsgSuggestions = "Suggestions:"
// CLIMsgCheckFilePermissions suggests checking file permissions.
CLIMsgCheckFilePermissions = " • Check file/directory permissions\n"
// CLIMsgCheckCommandLineArgs suggests checking command line arguments.
CLIMsgCheckCommandLineArgs = " • Check your command line arguments\n"
// CLIMsgRunWithHelp suggests running with help flag.
CLIMsgRunWithHelp = " • Run with --help for usage information\n"
)
// CLI Processing Messages
const (
// CLIMsgFoundFilesToProcess is the message format when files are found to process.
CLIMsgFoundFilesToProcess = "Found %d files to process"
// CLIMsgFileProcessingWorker is the worker identifier for file processing.
CLIMsgFileProcessingWorker = "file processing worker"
)
// CLI UI Constants
const (
// UIProgressBarChar is the character used for progress bar display.
UIProgressBarChar = "█"
)
// Error Format Strings
const (
// ErrorFmtWithCause is the format string for errors with cause information.
ErrorFmtWithCause = "%s: %v"
// LogLevelWarningAlias is an alias for the warning log level used in validation.
LogLevelWarningAlias = "warning"
)
// File Processing Constants
const (
// FileProcessingStreamChunkSize is the size of chunks when streaming large files (64KB).
FileProcessingStreamChunkSize = 64 * BytesPerKB
// FileProcessingStreamThreshold is the file size above which we use streaming (1MB).
FileProcessingStreamThreshold = BytesPerMB
// FileProcessingMaxMemoryBuffer is the maximum memory to use for buffering content (10MB).
FileProcessingMaxMemoryBuffer = 10 * BytesPerMB
)
// File Processing Error Messages
const (
// FileProcessingMsgFailedToProcess is the error message format for processing failures.
FileProcessingMsgFailedToProcess = "Failed to process file: %s"
// FileProcessingMsgSizeExceeds is the error message when file size exceeds limit.
FileProcessingMsgSizeExceeds = "file size (%d bytes) exceeds limit (%d bytes)"
)
// Metrics Constants
const (
// MetricsPhaseCollection represents the collection phase.
MetricsPhaseCollection = "collection"
// MetricsPhaseProcessing represents the processing phase.
MetricsPhaseProcessing = "processing"
// MetricsPhaseWriting represents the writing phase.
MetricsPhaseWriting = "writing"
// MetricsPhaseFinalize represents the finalize phase.
MetricsPhaseFinalize = "finalize"
// MetricsMaxInt64 is the maximum int64 value for initial smallest file tracking.
MetricsMaxInt64 = int64(^uint64(0) >> 1)
// MetricsPerformanceIndexCap is the maximum performance index value for reasonable indexing.
MetricsPerformanceIndexCap = 1000
)
// Metrics Format Strings
const (
// MetricsFmtProcessingTime is the format string for processing time display.
MetricsFmtProcessingTime = "Processing Time: %v\n"
// MetricsFmtFileCount is the format string for file count display.
MetricsFmtFileCount = " %s: %d files\n"
// MetricsFmtBytesShort is the format string for bytes without suffix.
MetricsFmtBytesShort = "%dB"
// MetricsFmtBytesHuman is the format string for human-readable bytes.
MetricsFmtBytesHuman = "%.1f%cB"
)
// ============================================================================
// YAML WRITER FORMATS
// ============================================================================
const (
// YAMLFmtFileEntry is the format string for YAML file entries.
YAMLFmtFileEntry = " - path: %s\n language: %s\n content: |\n"
)
// ============================================================================
// YAML/STRING LITERAL VALUES
// ============================================================================
const (
// LiteralTrue is the string literal "true" used in YAML/env comparisons.
LiteralTrue = "true"
// LiteralFalse is the string literal "false" used in YAML/env comparisons.
LiteralFalse = "false"
// LiteralNull is the string literal "null" used in YAML comparisons.
LiteralNull = "null"
// LiteralPackageMain is the string literal "package main" used in test files.
LiteralPackageMain = "package main"
)
// ============================================================================
// TEMPLATE CONSTANTS
// ============================================================================
const (
// TemplateFmtTimestamp is the Go time format for timestamps in templates.
TemplateFmtTimestamp = "2006-01-02 15:04:05"
)
// ============================================================================
// BENCHMARK CONSTANTS
// ============================================================================
const (
// BenchmarkDefaultFileCount is the default number of files to create for benchmarks.
BenchmarkDefaultFileCount = 100
// BenchmarkDefaultIterations is the default number of iterations for benchmarks.
BenchmarkDefaultIterations = 1000
)
// ============================================================================
// BENCHMARK MESSAGES
// ============================================================================
const (
// BenchmarkMsgFailedToCreateFiles is the error message when benchmark file creation fails.
BenchmarkMsgFailedToCreateFiles = "failed to create benchmark files"
// BenchmarkMsgCollectionFailed is the error message when collection benchmark fails.
BenchmarkMsgCollectionFailed = "benchmark file collection failed"
// BenchmarkMsgRunningCollection is the status message when running collection benchmark.
BenchmarkMsgRunningCollection = "Running file collection benchmark..."
// BenchmarkMsgFileCollectionFailed is the error message when file collection benchmark fails.
BenchmarkMsgFileCollectionFailed = "file collection benchmark failed"
// BenchmarkMsgConcurrencyFailed is the error message when concurrency benchmark fails.
BenchmarkMsgConcurrencyFailed = "concurrency benchmark failed"
// BenchmarkMsgFormatFailed is the error message when format benchmark fails.
BenchmarkMsgFormatFailed = "format benchmark failed"
// BenchmarkFmtSectionHeader is the format string for benchmark section headers.
BenchmarkFmtSectionHeader = "=== %s ===\n"
)
// Test File Permissions
const (
// TestFilePermission is the default file permission for test files.
TestFilePermission = 0o644
// TestDirPermission is the default directory permission for test directories.
TestDirPermission = 0o755
)
// Log Level Constants
const (
// LogLevelDebug logs all messages including debug information.
LogLevelDebug LogLevel = "debug"
// LogLevelInfo logs info, warning, and error messages.
LogLevelInfo LogLevel = "info"
// LogLevelWarn logs warning and error messages only.
LogLevelWarn LogLevel = "warn"
// LogLevelError logs error messages only.
LogLevelError LogLevel = "error"
)
// ============================================================================
// FORMAT CONSTANTS
// ============================================================================
const (
// FormatJSON is the JSON format identifier.
FormatJSON = "json"
// FormatYAML is the YAML format identifier.
FormatYAML = "yaml"
// FormatMarkdown is the Markdown format identifier.
FormatMarkdown = "markdown"
)
// ============================================================================
// CLI ARGUMENT NAMES
// ============================================================================
const (
// CLIArgSource is the source argument name.
CLIArgSource = "source"
// CLIArgFormat is the format argument name.
CLIArgFormat = "format"
// CLIArgConcurrency is the concurrency argument name.
CLIArgConcurrency = "concurrency"
// CLIArgAll is the all benchmarks argument value.
CLIArgAll = "all"
)
// ============================================================================
// APPLICATION CONSTANTS
// ============================================================================
const (
// AppName is the application name.
AppName = "gibidify"
)

74
shared/conversions.go Normal file
View File

@@ -0,0 +1,74 @@
// Package shared provides common utility functions for gibidify.
package shared
import (
"math"
)
// SafeUint64ToInt64 safely converts uint64 to int64, checking for overflow.
// Returns the converted value and true if conversion is safe, or 0 and false if overflow would occur.
func SafeUint64ToInt64(value uint64) (int64, bool) {
if value > math.MaxInt64 {
return 0, false
}
return int64(value), true
}
// SafeIntToInt32 safely converts int to int32, checking for overflow.
// Returns the converted value and true if conversion is safe, or 0 and false if overflow would occur.
func SafeIntToInt32(value int) (int32, bool) {
if value > math.MaxInt32 || value < math.MinInt32 {
return 0, false
}
return int32(value), true
}
// SafeUint64ToInt64WithDefault safely converts uint64 to int64 with a default value on overflow.
func SafeUint64ToInt64WithDefault(value uint64, defaultValue int64) int64 {
if converted, ok := SafeUint64ToInt64(value); ok {
return converted
}
return defaultValue
}
// SafeIntToInt32WithDefault safely converts int to int32 with a default value on overflow.
func SafeIntToInt32WithDefault(value int, defaultValue int32) int32 {
if converted, ok := SafeIntToInt32(value); ok {
return converted
}
return defaultValue
}
// BytesToMB safely converts bytes (uint64) to megabytes (int64), handling overflow.
func BytesToMB(bytes uint64) int64 {
mb := bytes / uint64(BytesPerMB)
return SafeUint64ToInt64WithDefault(mb, math.MaxInt64)
}
// BytesToMBFloat64 safely converts bytes (uint64) to megabytes (float64), handling overflow.
func BytesToMBFloat64(bytes uint64) float64 {
const bytesPerMB = float64(BytesPerMB)
if bytes > math.MaxUint64/2 {
// Prevent overflow in arithmetic by dividing step by step
return float64(bytes/uint64(BytesPerKB)) / float64(BytesPerKB)
}
return float64(bytes) / bytesPerMB
}
// SafeMemoryDiffMB safely calculates the difference between two uint64 memory values
// and converts to MB as float64, handling potential underflow.
func SafeMemoryDiffMB(after, before uint64) float64 {
if after >= before {
diff := after - before
return BytesToMBFloat64(diff)
}
// Handle underflow case - return 0 instead of negative
return 0.0
}

321
shared/conversions_test.go Normal file
View File

@@ -0,0 +1,321 @@
package shared
import (
"math"
"testing"
)
func TestSafeUint64ToInt64(t *testing.T) {
tests := []struct {
name string
input uint64
expected int64
wantOk bool
}{
{
name: TestSafeConversion,
input: 1000,
expected: 1000,
wantOk: true,
},
{
name: "max safe value",
input: math.MaxInt64,
expected: math.MaxInt64,
wantOk: true,
},
{
name: "overflow by one",
input: math.MaxInt64 + 1,
expected: 0,
wantOk: false,
},
{
name: "max uint64 overflow",
input: math.MaxUint64,
expected: 0,
wantOk: false,
},
{
name: "zero value",
input: 0,
expected: 0,
wantOk: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got, ok := SafeUint64ToInt64(tt.input)
if ok != tt.wantOk {
t.Errorf("SafeUint64ToInt64() ok = %v, want %v", ok, tt.wantOk)
}
if got != tt.expected {
t.Errorf("SafeUint64ToInt64() = %v, want %v", got, tt.expected)
}
},
)
}
}
func TestSafeIntToInt32(t *testing.T) {
tests := []struct {
name string
input int
expected int32
wantOk bool
}{
{
name: TestSafeConversion,
input: 1000,
expected: 1000,
wantOk: true,
},
{
name: "max safe value",
input: math.MaxInt32,
expected: math.MaxInt32,
wantOk: true,
},
{
name: "min safe value",
input: math.MinInt32,
expected: math.MinInt32,
wantOk: true,
},
{
name: "overflow by one",
input: math.MaxInt32 + 1,
expected: 0,
wantOk: false,
},
{
name: "underflow by one",
input: math.MinInt32 - 1,
expected: 0,
wantOk: false,
},
{
name: "zero value",
input: 0,
expected: 0,
wantOk: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got, ok := SafeIntToInt32(tt.input)
if ok != tt.wantOk {
t.Errorf("SafeIntToInt32() ok = %v, want %v", ok, tt.wantOk)
}
if got != tt.expected {
t.Errorf("SafeIntToInt32() = %v, want %v", got, tt.expected)
}
},
)
}
}
func TestSafeUint64ToInt64WithDefault(t *testing.T) {
tests := []struct {
name string
input uint64
defaultValue int64
expected int64
}{
{
name: TestSafeConversion,
input: 1000,
defaultValue: -1,
expected: 1000,
},
{
name: "overflow uses default",
input: math.MaxUint64,
defaultValue: -1,
expected: -1,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := SafeUint64ToInt64WithDefault(tt.input, tt.defaultValue)
if got != tt.expected {
t.Errorf("SafeUint64ToInt64WithDefault() = %v, want %v", got, tt.expected)
}
},
)
}
}
func TestSafeIntToInt32WithDefault(t *testing.T) {
tests := []struct {
name string
input int
defaultValue int32
expected int32
}{
{
name: TestSafeConversion,
input: 1000,
defaultValue: -1,
expected: 1000,
},
{
name: "overflow uses default",
input: math.MaxInt32 + 1,
defaultValue: -1,
expected: -1,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := SafeIntToInt32WithDefault(tt.input, tt.defaultValue)
if got != tt.expected {
t.Errorf("SafeIntToInt32WithDefault() = %v, want %v", got, tt.expected)
}
},
)
}
}
func TestBytesToMB(t *testing.T) {
tests := []struct {
name string
input uint64
expected int64
}{
{
name: "zero bytes",
input: 0,
expected: 0,
},
{
name: "1MB",
input: 1024 * 1024,
expected: 1,
},
{
name: "1GB",
input: 1024 * 1024 * 1024,
expected: 1024,
},
{
name: "large value (no overflow)",
input: math.MaxUint64,
expected: 17592186044415, // MaxUint64 / 1024 / 1024, which is still within int64 range
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := BytesToMB(tt.input)
if got != tt.expected {
t.Errorf("BytesToMB() = %v, want %v", got, tt.expected)
}
},
)
}
}
func TestBytesToMBFloat64(t *testing.T) {
tests := []struct {
name string
input uint64
expected float64
delta float64
}{
{
name: "zero bytes",
input: 0,
expected: 0,
delta: 0.0001,
},
{
name: "1MB",
input: 1024 * 1024,
expected: 1.0,
delta: 0.0001,
},
{
name: "1GB",
input: 1024 * 1024 * 1024,
expected: 1024.0,
delta: 0.0001,
},
{
name: "large value near overflow",
input: math.MaxUint64 - 1,
expected: float64((math.MaxUint64-1)/1024) / 1024.0,
delta: 1.0, // Allow larger delta for very large numbers
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := BytesToMBFloat64(tt.input)
if math.Abs(got-tt.expected) > tt.delta {
t.Errorf("BytesToMBFloat64() = %v, want %v (±%v)", got, tt.expected, tt.delta)
}
},
)
}
}
func TestSafeMemoryDiffMB(t *testing.T) {
tests := []struct {
name string
after uint64
before uint64
expected float64
delta float64
}{
{
name: "normal increase",
after: 2 * 1024 * 1024, // 2MB
before: 1 * 1024 * 1024, // 1MB
expected: 1.0,
delta: 0.0001,
},
{
name: "no change",
after: 1 * 1024 * 1024,
before: 1 * 1024 * 1024,
expected: 0.0,
delta: 0.0001,
},
{
name: "underflow case",
after: 1 * 1024 * 1024, // 1MB
before: 2 * 1024 * 1024, // 2MB
expected: 0.0, // Should return 0 instead of negative
delta: 0.0001,
},
{
name: "large difference",
after: 2 * 1024 * 1024 * 1024, // 2GB
before: 1 * 1024 * 1024 * 1024, // 1GB
expected: 1024.0, // 1GB = 1024MB
delta: 0.0001,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
got := SafeMemoryDiffMB(tt.after, tt.before)
if math.Abs(got-tt.expected) > tt.delta {
t.Errorf("SafeMemoryDiffMB() = %v, want %v (±%v)", got, tt.expected, tt.delta)
}
},
)
}
}

259
shared/errors.go Normal file
View File

@@ -0,0 +1,259 @@
// Package shared provides common utility functions.
package shared
import (
"errors"
"fmt"
)
// ErrorType represents the category of error.
type ErrorType int
const (
// ErrorTypeUnknown represents an unknown error type.
ErrorTypeUnknown ErrorType = iota
// ErrorTypeCLI represents command-line interface errors.
ErrorTypeCLI
// ErrorTypeFileSystem represents file system operation errors.
ErrorTypeFileSystem
// ErrorTypeProcessing represents file processing errors.
ErrorTypeProcessing
// ErrorTypeConfiguration represents configuration errors.
ErrorTypeConfiguration
// ErrorTypeIO represents input/output errors.
ErrorTypeIO
// ErrorTypeValidation represents validation errors.
ErrorTypeValidation
)
// String returns the string representation of the error type.
func (e ErrorType) String() string {
switch e {
case ErrorTypeCLI:
return "CLI"
case ErrorTypeFileSystem:
return "FileSystem"
case ErrorTypeProcessing:
return "Processing"
case ErrorTypeConfiguration:
return "Configuration"
case ErrorTypeIO:
return "IO"
case ErrorTypeValidation:
return "Validation"
default:
return "Unknown"
}
}
// StructuredError represents a structured error with type, code, and context.
type StructuredError struct {
Type ErrorType
Code string
Message string
Cause error
Context map[string]any
FilePath string
Line int
}
// Error implements the error interface.
func (e *StructuredError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s [%s]: %s: %v", e.Type, e.Code, e.Message, e.Cause)
}
return fmt.Sprintf("%s [%s]: %s", e.Type, e.Code, e.Message)
}
// Unwrap returns the underlying cause error.
func (e *StructuredError) Unwrap() error {
return e.Cause
}
// WithContext adds context information to the error.
func (e *StructuredError) WithContext(key string, value any) *StructuredError {
if e.Context == nil {
e.Context = make(map[string]any)
}
e.Context[key] = value
return e
}
// WithFilePath adds file path information to the error.
func (e *StructuredError) WithFilePath(filePath string) *StructuredError {
e.FilePath = filePath
return e
}
// WithLine adds line number information to the error.
func (e *StructuredError) WithLine(line int) *StructuredError {
e.Line = line
return e
}
// NewStructuredError creates a new structured error.
func NewStructuredError(errorType ErrorType, code, message, filePath string, context map[string]any) *StructuredError {
return &StructuredError{
Type: errorType,
Code: code,
Message: message,
FilePath: filePath,
Context: context,
}
}
// NewStructuredErrorf creates a new structured error with formatted message.
func NewStructuredErrorf(errorType ErrorType, code, format string, args ...any) *StructuredError {
return &StructuredError{
Type: errorType,
Code: code,
Message: fmt.Sprintf(format, args...),
}
}
// WrapError wraps an existing error with structured error information.
func WrapError(err error, errorType ErrorType, code, message string) *StructuredError {
return &StructuredError{
Type: errorType,
Code: code,
Message: message,
Cause: err,
}
}
// WrapErrorf wraps an existing error with formatted message.
func WrapErrorf(err error, errorType ErrorType, code, format string, args ...any) *StructuredError {
return &StructuredError{
Type: errorType,
Code: code,
Message: fmt.Sprintf(format, args...),
Cause: err,
}
}
// Common error codes for each type.
const (
// CodeCLIMissingSource CLI Error Codes.
CodeCLIMissingSource = "MISSING_SOURCE"
CodeCLIInvalidArgs = "INVALID_ARGS"
// CodeFSPathResolution FileSystem Error Codes.
CodeFSPathResolution = "PATH_RESOLUTION"
CodeFSPermission = "PERMISSION_DENIED"
CodeFSNotFound = "NOT_FOUND"
CodeFSAccess = "ACCESS_DENIED"
// CodeProcessingFileRead Processing Error Codes.
CodeProcessingFileRead = "FILE_READ"
CodeProcessingCollection = "COLLECTION"
CodeProcessingTraversal = "TRAVERSAL"
CodeProcessingEncode = "ENCODE"
// CodeConfigValidation Configuration Error Codes.
CodeConfigValidation = "VALIDATION"
CodeConfigMissing = "MISSING"
// CodeIOFileCreate IO Error Codes.
CodeIOFileCreate = "FILE_CREATE"
CodeIOFileWrite = "FILE_WRITE"
CodeIOEncoding = "ENCODING"
CodeIOWrite = "WRITE"
CodeIORead = "READ"
CodeIOClose = "CLOSE"
// Validation Error Codes.
CodeValidationFormat = "FORMAT"
CodeValidationFileType = "FILE_TYPE"
CodeValidationSize = "SIZE_LIMIT"
CodeValidationRequired = "REQUIRED"
CodeValidationPath = "PATH_TRAVERSAL"
// Resource Limit Error Codes.
CodeResourceLimitFiles = "FILE_COUNT_LIMIT"
CodeResourceLimitTotalSize = "TOTAL_SIZE_LIMIT"
CodeResourceLimitTimeout = "TIMEOUT"
CodeResourceLimitMemory = "MEMORY_LIMIT"
CodeResourceLimitConcurrency = "CONCURRENCY_LIMIT"
CodeResourceLimitRate = "RATE_LIMIT"
)
// Predefined error constructors for common error scenarios
// NewMissingSourceError creates a CLI error for missing source argument.
func NewMissingSourceError() *StructuredError {
return NewStructuredError(
ErrorTypeCLI,
CodeCLIMissingSource,
"usage: gibidify -source <source_directory> [--destination <output_file>] "+
"[--format=json|yaml|markdown (default: json)]",
"",
nil,
)
}
// NewFileSystemError creates a file system error.
func NewFileSystemError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeFileSystem, code, message, "", nil)
}
// NewProcessingError creates a processing error.
func NewProcessingError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeProcessing, code, message, "", nil)
}
// NewIOError creates an IO error.
func NewIOError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeIO, code, message, "", nil)
}
// NewValidationError creates a validation error.
func NewValidationError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeValidation, code, message, "", nil)
}
// LogError logs an error with a consistent format if the error is not nil.
// The operation parameter describes what was being attempted.
// Additional context can be provided via the args parameter.
func LogError(operation string, err error, args ...any) {
if err != nil {
msg := operation
if len(args) > 0 {
// Format the operation string with the provided arguments
msg = fmt.Sprintf(operation, args...)
}
logger := GetLogger()
// Check if it's a structured error and log with additional context
structErr := &StructuredError{}
if errors.As(err, &structErr) {
fields := map[string]any{
"error_type": structErr.Type.String(),
"error_code": structErr.Code,
"context": structErr.Context,
"file_path": structErr.FilePath,
"line": structErr.Line,
}
logger.WithFields(fields).Errorf(ErrorFmtWithCause, msg, err)
} else {
logger.Errorf(ErrorFmtWithCause, msg, err)
}
}
}
// LogErrorf logs an error with a formatted message if the error is not nil.
// This is a convenience wrapper around LogError for cases where formatting is needed.
func LogErrorf(err error, format string, args ...any) {
if err != nil {
LogError(format, err, args...)
}
}
// Test error variables.
var (
// ErrTestError is a generic test error.
ErrTestError = errors.New(TestErrTestErrorMsg)
)

932
shared/errors_test.go Normal file
View File

@@ -0,0 +1,932 @@
package shared
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"testing"
)
// captureLogOutput captures logger output for testing.
func captureLogOutput(f func()) string {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
defer logger.SetOutput(io.Discard) // Set to discard to avoid test output noise
f()
return buf.String()
}
func TestLogError(t *testing.T) {
tests := []struct {
name string
operation string
err error
args []any
wantLog string
wantEmpty bool
}{
{
name: "nil error should not log",
operation: "test operation",
err: nil,
args: nil,
wantEmpty: true,
},
{
name: "basic error logging",
operation: "failed to read file",
err: errors.New("permission denied"),
args: nil,
wantLog: "failed to read file: permission denied",
},
{
name: "error with formatting args",
operation: "failed to process file %s",
err: errors.New("file too large"),
args: []any{"test.txt"},
wantLog: "failed to process file test.txt: file too large",
},
{
name: "error with multiple formatting args",
operation: "failed to copy from %s to %s",
err: errors.New(TestErrDiskFull),
args: []any{"source.txt", "dest.txt"},
wantLog: "failed to copy from source.txt to dest.txt: disk full",
},
{
name: "wrapped error",
operation: "database operation failed",
err: fmt.Errorf("connection error: %w", errors.New("timeout")),
args: nil,
wantLog: "database operation failed: connection error: timeout",
},
{
name: "empty operation string",
operation: "",
err: errors.New("some error"),
args: nil,
wantLog: ": some error",
},
{
name: "operation with percentage sign",
operation: "processing 50% complete",
err: errors.New("interrupted"),
args: nil,
wantLog: "processing 50% complete: interrupted",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
output := captureLogOutput(
func() {
LogError(tt.operation, tt.err, tt.args...)
},
)
if tt.wantEmpty {
if output != "" {
t.Errorf("LogError() logged output when error was nil: %q", output)
}
return
}
if !strings.Contains(output, tt.wantLog) {
t.Errorf("LogError() output = %q, want to contain %q", output, tt.wantLog)
}
// Verify it's logged at ERROR level
if !strings.Contains(output, "level=error") {
t.Errorf("LogError() should log at ERROR level, got: %q", output)
}
},
)
}
}
func TestLogErrorf(t *testing.T) {
tests := []struct {
name string
err error
format string
args []any
wantLog string
wantEmpty bool
}{
{
name: "nil error should not log",
err: nil,
format: "operation %s failed",
args: []any{"test"},
wantEmpty: true,
},
{
name: "basic formatted error",
err: errors.New("not found"),
format: "file %s not found",
args: []any{"config.yaml"},
wantLog: "file config.yaml not found: not found",
},
{
name: "multiple format arguments",
err: errors.New("invalid range"),
format: "value %d is not between %d and %d",
args: []any{150, 0, 100},
wantLog: "value 150 is not between 0 and 100: invalid range",
},
{
name: "no format arguments",
err: errors.New("generic error"),
format: "operation failed",
args: nil,
wantLog: "operation failed: generic error",
},
{
name: "format with different types",
err: errors.New("type mismatch"),
format: "expected %s but got %d",
args: []any{"string", 42},
wantLog: "expected string but got 42: type mismatch",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
output := captureLogOutput(
func() {
LogErrorf(tt.err, tt.format, tt.args...)
},
)
if tt.wantEmpty {
if output != "" {
t.Errorf("LogErrorf() logged output when error was nil: %q", output)
}
return
}
if !strings.Contains(output, tt.wantLog) {
t.Errorf("LogErrorf() output = %q, want to contain %q", output, tt.wantLog)
}
// Verify it's logged at ERROR level
if !strings.Contains(output, "level=error") {
t.Errorf("LogErrorf() should log at ERROR level, got: %q", output)
}
},
)
}
}
func TestLogErrorConcurrency(_ *testing.T) {
// Test that LogError is safe for concurrent use
done := make(chan bool)
for i := range 10 {
go func(n int) {
LogError("concurrent operation", fmt.Errorf("error %d", n))
done <- true
}(i)
}
// Wait for all goroutines to complete
for range 10 {
<-done
}
}
func TestLogErrorfConcurrency(_ *testing.T) {
// Test that LogErrorf is safe for concurrent use
done := make(chan bool)
for i := range 10 {
go func(n int) {
LogErrorf(fmt.Errorf("error %d", n), "concurrent operation %d", n)
done <- true
}(i)
}
// Wait for all goroutines to complete
for range 10 {
<-done
}
}
// BenchmarkLogError benchmarks the LogError function.
func BenchmarkLogError(b *testing.B) {
err := errors.New("benchmark error")
// Disable output during benchmark
logger := GetLogger()
logger.SetOutput(io.Discard)
defer logger.SetOutput(io.Discard)
for b.Loop() {
LogError("benchmark operation", err)
}
}
// BenchmarkLogErrorf benchmarks the LogErrorf function.
func BenchmarkLogErrorf(b *testing.B) {
err := errors.New("benchmark error")
// Disable output during benchmark
logger := GetLogger()
logger.SetOutput(io.Discard)
defer logger.SetOutput(io.Discard)
b.ResetTimer()
for i := 0; i < b.N; i++ {
LogErrorf(err, "benchmark operation %d", i)
}
}
// BenchmarkLogErrorNil benchmarks LogError with nil error (no-op case).
func BenchmarkLogErrorNil(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
LogError("benchmark operation", nil)
}
}
func TestErrorTypeString(t *testing.T) {
tests := []struct {
name string
errType ErrorType
expected string
}{
{
name: "CLI error type",
errType: ErrorTypeCLI,
expected: "CLI",
},
{
name: "FileSystem error type",
errType: ErrorTypeFileSystem,
expected: "FileSystem",
},
{
name: "Processing error type",
errType: ErrorTypeProcessing,
expected: "Processing",
},
{
name: "Configuration error type",
errType: ErrorTypeConfiguration,
expected: "Configuration",
},
{
name: "IO error type",
errType: ErrorTypeIO,
expected: "IO",
},
{
name: "Validation error type",
errType: ErrorTypeValidation,
expected: "Validation",
},
{
name: "Unknown error type",
errType: ErrorTypeUnknown,
expected: "Unknown",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := tt.errType.String()
if result != tt.expected {
t.Errorf("ErrorType.String() = %q, want %q", result, tt.expected)
}
},
)
}
}
func TestStructuredErrorError(t *testing.T) {
tests := []struct {
name string
err *StructuredError
expected string
}{
{
name: "error without cause",
err: &StructuredError{
Type: ErrorTypeFileSystem,
Code: "ACCESS_DENIED",
Message: "permission denied",
},
expected: "FileSystem [ACCESS_DENIED]: permission denied",
},
{
name: "error with cause",
err: &StructuredError{
Type: ErrorTypeIO,
Code: "WRITE_FAILED",
Message: "unable to write file",
Cause: errors.New(TestErrDiskFull),
},
expected: "IO [WRITE_FAILED]: unable to write file: disk full",
},
{
name: "error with empty message",
err: &StructuredError{
Type: ErrorTypeValidation,
Code: "INVALID_FORMAT",
Message: "",
},
expected: "Validation [INVALID_FORMAT]: ",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := tt.err.Error()
if result != tt.expected {
t.Errorf("StructuredError.Error() = %q, want %q", result, tt.expected)
}
},
)
}
}
func TestStructuredErrorUnwrap(t *testing.T) {
originalErr := errors.New("original error")
tests := []struct {
name string
err *StructuredError
expected error
}{
{
name: "error with cause",
err: &StructuredError{
Type: ErrorTypeIO,
Code: "READ_FAILED",
Cause: originalErr,
},
expected: originalErr,
},
{
name: "error without cause",
err: &StructuredError{
Type: ErrorTypeValidation,
Code: "INVALID_INPUT",
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := tt.err.Unwrap()
if !errors.Is(result, tt.expected) {
t.Errorf("StructuredError.Unwrap() = %v, want %v", result, tt.expected)
}
},
)
}
}
func TestStructuredErrorWithContext(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeProcessing,
Code: "PROCESSING_FAILED",
Message: "processing error",
}
// Test adding context to error without existing context
result := err.WithContext("key1", "value1")
// Should return the same error instance
if !errors.Is(result, err) {
t.Error("WithContext() should return the same error instance")
}
// Check that context was added
if len(err.Context) != 1 {
t.Errorf("Expected context length 1, got %d", len(err.Context))
}
if err.Context["key1"] != "value1" {
t.Errorf("Expected context key1=value1, got %v", err.Context["key1"])
}
// Test adding more context
err = err.WithContext("key2", 42)
if len(err.Context) != 2 {
t.Errorf("Expected context length 2, got %d", len(err.Context))
}
if err.Context["key2"] != 42 {
t.Errorf("Expected context key2=42, got %v", err.Context["key2"])
}
}
func TestStructuredErrorWithFilePath(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeFileSystem,
Code: "FILE_NOT_FOUND",
Message: "file not found",
}
filePath := "/path/to/file.txt"
result := err.WithFilePath(filePath)
// Should return the same error instance
if !errors.Is(result, err) {
t.Error("WithFilePath() should return the same error instance")
}
// Check that file path was set
if err.FilePath != filePath {
t.Errorf(TestFmtExpectedFilePath, filePath, err.FilePath)
}
// Test overwriting existing file path
newPath := "/another/path.txt"
err = err.WithFilePath(newPath)
if err.FilePath != newPath {
t.Errorf(TestFmtExpectedFilePath, newPath, err.FilePath)
}
}
func TestStructuredErrorWithLine(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeValidation,
Code: "SYNTAX_ERROR",
Message: "syntax error",
}
lineNum := 42
result := err.WithLine(lineNum)
// Should return the same error instance
if !errors.Is(result, err) {
t.Error("WithLine() should return the same error instance")
}
// Check that line number was set
if err.Line != lineNum {
t.Errorf(TestFmtExpectedLine, lineNum, err.Line)
}
// Test overwriting existing line number
newLine := 100
err = err.WithLine(newLine)
if err.Line != newLine {
t.Errorf(TestFmtExpectedLine, newLine, err.Line)
}
}
// validateStructuredErrorBasics validates basic structured error fields.
func validateStructuredErrorBasics(
t *testing.T,
err *StructuredError,
errorType ErrorType,
code, message, filePath string,
) {
t.Helper()
if err.Type != errorType {
t.Errorf(TestFmtExpectedType, errorType, err.Type)
}
if err.Code != code {
t.Errorf(TestFmtExpectedCode, code, err.Code)
}
if err.Message != message {
t.Errorf(TestFmtExpectedMessage, message, err.Message)
}
if err.FilePath != filePath {
t.Errorf(TestFmtExpectedFilePath, filePath, err.FilePath)
}
}
// validateStructuredErrorContext validates context fields.
func validateStructuredErrorContext(t *testing.T, err *StructuredError, expectedContext map[string]any) {
t.Helper()
if expectedContext == nil {
if len(err.Context) != 0 {
t.Errorf("Expected empty context, got %v", err.Context)
}
return
}
if len(err.Context) != len(expectedContext) {
t.Errorf("Expected context length %d, got %d", len(expectedContext), len(err.Context))
}
for k, v := range expectedContext {
if err.Context[k] != v {
t.Errorf("Expected context[%q] = %v, got %v", k, v, err.Context[k])
}
}
}
func TestNewStructuredError(t *testing.T) {
tests := []struct {
name string
errorType ErrorType
code string
message string
filePath string
context map[string]any
}{
{
name: "basic structured error",
errorType: ErrorTypeFileSystem,
code: "ACCESS_DENIED",
message: TestErrAccessDenied,
filePath: "/test/file.txt",
context: nil,
},
{
name: "error with context",
errorType: ErrorTypeValidation,
code: "INVALID_FORMAT",
message: "invalid format",
filePath: "",
context: map[string]any{
"expected": "json",
"got": "xml",
},
},
{
name: "error with all fields",
errorType: ErrorTypeIO,
code: "WRITE_FAILED",
message: "write failed",
filePath: "/output/file.txt",
context: map[string]any{
"bytes_written": 1024,
"total_size": 2048,
},
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
err := NewStructuredError(tt.errorType, tt.code, tt.message, tt.filePath, tt.context)
validateStructuredErrorBasics(t, err, tt.errorType, tt.code, tt.message, tt.filePath)
validateStructuredErrorContext(t, err, tt.context)
},
)
}
}
func TestNewStructuredErrorf(t *testing.T) {
tests := []struct {
name string
errorType ErrorType
code string
format string
args []any
expectedMsg string
}{
{
name: "formatted error without args",
errorType: ErrorTypeProcessing,
code: "PROCESSING_FAILED",
format: TestErrProcessingFailed,
args: nil,
expectedMsg: TestErrProcessingFailed,
},
{
name: "formatted error with args",
errorType: ErrorTypeValidation,
code: "INVALID_VALUE",
format: "invalid value %q, expected between %d and %d",
args: []any{"150", 0, 100},
expectedMsg: "invalid value \"150\", expected between 0 and 100",
},
{
name: "formatted error with multiple types",
errorType: ErrorTypeIO,
code: "READ_ERROR",
format: "failed to read %d bytes from %s",
args: []any{1024, "/tmp/file.txt"},
expectedMsg: "failed to read 1024 bytes from /tmp/file.txt",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
err := NewStructuredErrorf(tt.errorType, tt.code, tt.format, tt.args...)
if err.Type != tt.errorType {
t.Errorf(TestFmtExpectedType, tt.errorType, err.Type)
}
if err.Code != tt.code {
t.Errorf(TestFmtExpectedCode, tt.code, err.Code)
}
if err.Message != tt.expectedMsg {
t.Errorf(TestFmtExpectedMessage, tt.expectedMsg, err.Message)
}
},
)
}
}
// validateWrapErrorResult validates wrap error results.
func validateWrapErrorResult(
t *testing.T,
result *StructuredError,
originalErr error,
errorType ErrorType,
code, message string,
) {
t.Helper()
if result.Type != errorType {
t.Errorf(TestFmtExpectedType, errorType, result.Type)
}
if result.Code != code {
t.Errorf(TestFmtExpectedCode, code, result.Code)
}
if result.Message != message {
t.Errorf(TestFmtExpectedMessage, message, result.Message)
}
if !errors.Is(result.Cause, originalErr) {
t.Errorf("Expected Cause %v, got %v", originalErr, result.Cause)
}
if originalErr != nil && !errors.Is(result, originalErr) {
t.Error("Expected errors.Is to return true for wrapped error")
}
}
func TestWrapError(t *testing.T) {
originalErr := errors.New("original error")
tests := []struct {
name string
err error
errorType ErrorType
code string
message string
}{
{
name: "wrap simple error",
err: originalErr,
errorType: ErrorTypeFileSystem,
code: "ACCESS_DENIED",
message: TestErrAccessDenied,
},
{
name: "wrap nil error",
err: nil,
errorType: ErrorTypeValidation,
code: "INVALID_INPUT",
message: "invalid input",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := WrapError(tt.err, tt.errorType, tt.code, tt.message)
validateWrapErrorResult(t, result, tt.err, tt.errorType, tt.code, tt.message)
},
)
}
}
func TestWrapErrorf(t *testing.T) {
originalErr := errors.New(TestErrDiskFull)
tests := []struct {
name string
err error
errorType ErrorType
code string
format string
args []any
expectedMsg string
}{
{
name: "wrap with formatted message",
err: originalErr,
errorType: ErrorTypeIO,
code: "WRITE_FAILED",
format: "failed to write %d bytes to %s",
args: []any{1024, "/tmp/output.txt"},
expectedMsg: "failed to write 1024 bytes to /tmp/output.txt",
},
{
name: "wrap without args",
err: originalErr,
errorType: ErrorTypeProcessing,
code: "PROCESSING_ERROR",
format: TestErrProcessingFailed,
args: nil,
expectedMsg: TestErrProcessingFailed,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := WrapErrorf(tt.err, tt.errorType, tt.code, tt.format, tt.args...)
if result.Type != tt.errorType {
t.Errorf(TestFmtExpectedType, tt.errorType, result.Type)
}
if result.Code != tt.code {
t.Errorf(TestFmtExpectedCode, tt.code, result.Code)
}
if result.Message != tt.expectedMsg {
t.Errorf(TestFmtExpectedMessage, tt.expectedMsg, result.Message)
}
if !errors.Is(result.Cause, tt.err) {
t.Errorf("Expected Cause %v, got %v", tt.err, result.Cause)
}
},
)
}
}
// validatePredefinedError validates predefined error constructor results.
func validatePredefinedError(t *testing.T, err *StructuredError, expectedType ErrorType, name, code, message string) {
t.Helper()
if err.Type != expectedType {
t.Errorf(TestFmtExpectedType, expectedType, err.Type)
}
if name != "NewMissingSourceError" {
if err.Code != code {
t.Errorf(TestFmtExpectedCode, code, err.Code)
}
if err.Message != message {
t.Errorf(TestFmtExpectedMessage, message, err.Message)
}
}
}
func TestPredefinedErrorConstructors(t *testing.T) {
tests := []struct {
name string
constructor func(string, string) *StructuredError
expectedType ErrorType
}{
{
name: "NewMissingSourceError",
constructor: func(_, _ string) *StructuredError { return NewMissingSourceError() },
expectedType: ErrorTypeCLI,
},
{
name: "NewFileSystemError",
constructor: NewFileSystemError,
expectedType: ErrorTypeFileSystem,
},
{
name: "NewProcessingError",
constructor: NewProcessingError,
expectedType: ErrorTypeProcessing,
},
{
name: "NewIOError",
constructor: NewIOError,
expectedType: ErrorTypeIO,
},
{
name: "NewValidationError",
constructor: NewValidationError,
expectedType: ErrorTypeValidation,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
code := "TEST_CODE"
message := "test message"
var err *StructuredError
if tt.name == "NewMissingSourceError" {
err = NewMissingSourceError()
} else {
err = tt.constructor(code, message)
}
validatePredefinedError(t, err, tt.expectedType, tt.name, code, message)
},
)
}
}
func TestStructuredErrorIntegration(t *testing.T) {
// Test a complete structured error workflow
originalErr := errors.New("connection timeout")
// Create and modify error through chaining
err := WrapError(originalErr, ErrorTypeIO, "READ_TIMEOUT", "failed to read from network").
WithFilePath(TestPathTmpNetworkData).
WithLine(42).
WithContext("host", "example.com").
WithContext("port", 8080)
// Test error interface implementation
errorMsg := err.Error()
expectedMsg := "IO [READ_TIMEOUT]: failed to read from network: connection timeout"
if errorMsg != expectedMsg {
t.Errorf("Expected error message %q, got %q", expectedMsg, errorMsg)
}
// Test unwrapping
if !errors.Is(err, originalErr) {
t.Error("Expected errors.Is to return true for wrapped error")
}
// Test properties
if err.FilePath != TestPathTmpNetworkData {
t.Errorf(TestFmtExpectedFilePath, TestPathTmpNetworkData, err.FilePath)
}
if err.Line != 42 {
t.Errorf(TestFmtExpectedLine, 42, err.Line)
}
if len(err.Context) != 2 {
t.Errorf("Expected context length 2, got %d", len(err.Context))
}
if err.Context["host"] != "example.com" {
t.Errorf("Expected context host=example.com, got %v", err.Context["host"])
}
if err.Context["port"] != 8080 {
t.Errorf("Expected context port=8080, got %v", err.Context["port"])
}
}
func TestErrorTypeConstants(t *testing.T) {
// Test that all error type constants are properly defined
types := []ErrorType{
ErrorTypeCLI,
ErrorTypeFileSystem,
ErrorTypeProcessing,
ErrorTypeConfiguration,
ErrorTypeIO,
ErrorTypeValidation,
ErrorTypeUnknown,
}
// Ensure all types have unique string representations
seen := make(map[string]bool)
for _, errType := range types {
str := errType.String()
if seen[str] {
t.Errorf("Duplicate string representation: %q", str)
}
seen[str] = true
if str == "" {
t.Errorf("Empty string representation for error type %v", errType)
}
}
}
// Benchmark tests for StructuredError operations.
func BenchmarkNewStructuredError(b *testing.B) {
context := map[string]any{
"key1": "value1",
"key2": 42,
}
for b.Loop() {
_ = NewStructuredError( // nolint:errcheck // benchmark test
ErrorTypeFileSystem,
"ACCESS_DENIED",
TestErrAccessDenied,
"/test/file.txt",
context,
)
}
}
func BenchmarkStructuredErrorError(b *testing.B) {
err := NewStructuredError(ErrorTypeIO, "WRITE_FAILED", "write operation failed", "/tmp/file.txt", nil)
for b.Loop() {
_ = err.Error()
}
}
func BenchmarkStructuredErrorWithContext(b *testing.B) {
err := NewStructuredError(ErrorTypeProcessing, "PROC_FAILED", TestErrProcessingFailed, "", nil)
for i := 0; b.Loop(); i++ {
_ = err.WithContext(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)) // nolint:errcheck // benchmark test
}
}

164
shared/logger.go Normal file
View File

@@ -0,0 +1,164 @@
// Package shared provides logging utilities for gibidify.
package shared
import (
"io"
"os"
"sync"
"github.com/sirupsen/logrus"
)
// Logger interface defines the logging contract for gibidify.
type Logger interface {
Debug(args ...any)
Debugf(format string, args ...any)
Info(args ...any)
Infof(format string, args ...any)
Warn(args ...any)
Warnf(format string, args ...any)
Error(args ...any)
Errorf(format string, args ...any)
WithFields(fields map[string]any) Logger
SetLevel(level LogLevel)
SetOutput(output io.Writer)
}
// LogLevel represents available log levels.
type LogLevel string
// logService implements the Logger interface using logrus.
type logService struct {
logger *logrus.Logger
entry *logrus.Entry
}
var (
instance Logger
once sync.Once
)
// GetLogger returns the singleton logger instance.
// Default level is WARNING to reduce noise in CLI output.
func GetLogger() Logger {
once.Do(
func() {
logger := logrus.New()
logger.SetLevel(logrus.WarnLevel) // Default to WARNING level
logger.SetOutput(os.Stderr)
logger.SetFormatter(
&logrus.TextFormatter{
DisableColors: false,
FullTimestamp: false,
},
)
instance = &logService{
logger: logger,
entry: logger.WithFields(logrus.Fields{}),
}
},
)
return instance
}
// Debug logs a debug message.
func (l *logService) Debug(args ...any) {
l.entry.Debug(args...)
}
// Debugf logs a formatted debug message.
func (l *logService) Debugf(format string, args ...any) {
l.entry.Debugf(format, args...)
}
// Info logs an info message.
func (l *logService) Info(args ...any) {
l.entry.Info(args...)
}
// Infof logs a formatted info message.
func (l *logService) Infof(format string, args ...any) {
l.entry.Infof(format, args...)
}
// Warn logs a warning message.
func (l *logService) Warn(args ...any) {
l.entry.Warn(args...)
}
// Warnf logs a formatted warning message.
func (l *logService) Warnf(format string, args ...any) {
l.entry.Warnf(format, args...)
}
// Error logs an error message.
func (l *logService) Error(args ...any) {
l.entry.Error(args...)
}
// Errorf logs a formatted error message.
func (l *logService) Errorf(format string, args ...any) {
l.entry.Errorf(format, args...)
}
// WithFields adds structured fields to log entries.
func (l *logService) WithFields(fields map[string]any) Logger {
logrusFields := make(logrus.Fields)
for k, v := range fields {
logrusFields[k] = v
}
return &logService{
logger: l.logger,
entry: l.entry.WithFields(logrusFields),
}
}
// SetLevel sets the logging level.
func (l *logService) SetLevel(level LogLevel) {
var logrusLevel logrus.Level
switch level {
case LogLevelDebug:
logrusLevel = logrus.DebugLevel
case LogLevelInfo:
logrusLevel = logrus.InfoLevel
case LogLevelError:
logrusLevel = logrus.ErrorLevel
default:
// LogLevelWarn and unknown levels default to warn
logrusLevel = logrus.WarnLevel
}
l.logger.SetLevel(logrusLevel)
}
// SetOutput sets the output destination for logs.
func (l *logService) SetOutput(output io.Writer) {
l.logger.SetOutput(output)
}
// ParseLogLevel parses string log level to LogLevel.
func ParseLogLevel(level string) LogLevel {
switch level {
case string(LogLevelDebug):
return LogLevelDebug
case string(LogLevelInfo):
return LogLevelInfo
case string(LogLevelError):
return LogLevelError
default:
// "warn", "warning", and unknown levels default to warn
return LogLevelWarn
}
}
// ValidateLogLevel validates if the provided log level is valid.
func ValidateLogLevel(level string) bool {
switch level {
case string(LogLevelDebug), string(LogLevelInfo), string(LogLevelWarn), LogLevelWarningAlias, string(LogLevelError):
return true
default:
return false
}
}

376
shared/logger_test.go Normal file
View File

@@ -0,0 +1,376 @@
package shared
import (
"bytes"
"io"
"strings"
"testing"
)
func TestGetLogger(t *testing.T) {
// Test singleton behavior
logger1 := GetLogger()
logger2 := GetLogger()
if logger1 != logger2 {
t.Error("GetLogger should return the same instance (singleton)")
}
}
func TestLogServiceLevels(t *testing.T) {
tests := []struct {
name string
level LogLevel
logFunc func(Logger)
expected bool
}{
{
name: "debug level allows debug messages",
level: LogLevelDebug,
logFunc: func(l Logger) {
l.Debug(TestLoggerDebugMsg)
},
expected: true,
},
{
name: "info level blocks debug messages",
level: LogLevelInfo,
logFunc: func(l Logger) {
l.Debug(TestLoggerDebugMsg)
},
expected: false,
},
{
name: "info level allows info messages",
level: LogLevelInfo,
logFunc: func(l Logger) {
l.Info(TestLoggerInfoMsg)
},
expected: true,
},
{
name: "warn level blocks info messages",
level: LogLevelWarn,
logFunc: func(l Logger) {
l.Info(TestLoggerInfoMsg)
},
expected: false,
},
{
name: "warn level allows warn messages",
level: LogLevelWarn,
logFunc: func(l Logger) {
l.Warn(TestLoggerWarnMsg)
},
expected: true,
},
{
name: "error level blocks warn messages",
level: LogLevelError,
logFunc: func(l Logger) {
l.Warn(TestLoggerWarnMsg)
},
expected: false,
},
{
name: "error level allows error messages",
level: LogLevelError,
logFunc: func(l Logger) {
l.Error("error message")
},
expected: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
logger.SetLevel(tt.level)
tt.logFunc(logger)
output := buf.String()
hasOutput := len(strings.TrimSpace(output)) > 0
if hasOutput != tt.expected {
t.Errorf("Expected output: %v, got output: %v, output: %s", tt.expected, hasOutput, output)
}
},
)
}
}
func TestLogServiceFormattedLogging(t *testing.T) {
tests := []struct {
name string
level LogLevel
logFunc func(Logger)
contains string
}{
{
name: "debugf formats correctly",
level: LogLevelDebug,
logFunc: func(l Logger) {
l.Debugf("debug %s %d", "message", 42)
},
contains: "debug message 42",
},
{
name: "infof formats correctly",
level: LogLevelInfo,
logFunc: func(l Logger) {
l.Infof("info %s %d", "message", 42)
},
contains: "info message 42",
},
{
name: "warnf formats correctly",
level: LogLevelWarn,
logFunc: func(l Logger) {
l.Warnf("warn %s %d", "message", 42)
},
contains: "warn message 42",
},
{
name: "errorf formats correctly",
level: LogLevelError,
logFunc: func(l Logger) {
l.Errorf("error %s %d", "message", 42)
},
contains: "error message 42",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
logger.SetLevel(tt.level)
tt.logFunc(logger)
output := buf.String()
if !strings.Contains(output, tt.contains) {
t.Errorf("Expected output to contain %q, got: %s", tt.contains, output)
}
},
)
}
}
func TestLogServiceWithFields(t *testing.T) {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
logger.SetLevel(LogLevelInfo)
fields := map[string]any{
"component": "test",
"count": 42,
"enabled": true,
}
fieldLogger := logger.WithFields(fields)
fieldLogger.Info("test message")
output := buf.String()
expectedFields := []string{"component=test", "count=42", "enabled=true", "test message"}
for _, expected := range expectedFields {
if !strings.Contains(output, expected) {
t.Errorf("Expected output to contain %q, got: %s", expected, output)
}
}
}
func TestLogServiceSetOutput(t *testing.T) {
var buf1, buf2 bytes.Buffer
logger := GetLogger()
// Set initial output
logger.SetOutput(&buf1)
logger.SetLevel(LogLevelInfo)
logger.Info("message1")
// Change output
logger.SetOutput(&buf2)
logger.Info("message2")
// Verify messages went to correct outputs
if !strings.Contains(buf1.String(), "message1") {
t.Error("First message should be in first buffer")
}
if strings.Contains(buf1.String(), "message2") {
t.Error("Second message should not be in first buffer")
}
if !strings.Contains(buf2.String(), "message2") {
t.Error("Second message should be in second buffer")
}
}
func TestParseLogLevel(t *testing.T) {
tests := []struct {
input string
expected LogLevel
}{
{"debug", LogLevelDebug},
{"info", LogLevelInfo},
{"warn", LogLevelWarn},
{"warning", LogLevelWarn},
{"error", LogLevelError},
{"invalid", LogLevelWarn}, // default
{"", LogLevelWarn}, // default
}
for _, tt := range tests {
t.Run(
tt.input, func(t *testing.T) {
result := ParseLogLevel(tt.input)
if result != tt.expected {
t.Errorf("ParseLogLevel(%q) = %v, want %v", tt.input, result, tt.expected)
}
},
)
}
}
func TestValidateLogLevel(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"debug", true},
{"info", true},
{"warn", true},
{"warning", true},
{"error", true},
{"invalid", false},
{"", false},
{"DEBUG", false}, // case-sensitive
{"INFO", false}, // case-sensitive
}
for _, tt := range tests {
t.Run(
tt.input, func(t *testing.T) {
result := ValidateLogLevel(tt.input)
if result != tt.expected {
t.Errorf("ValidateLogLevel(%q) = %v, want %v", tt.input, result, tt.expected)
}
},
)
}
}
func TestLogServiceDefaultLevel(t *testing.T) {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
logger.SetLevel(LogLevelWarn) // Ensure we're at WARN level for this test
// Test that default level is WARN (should block info messages)
logger.Info(TestLoggerInfoMsg)
if strings.TrimSpace(buf.String()) != "" {
t.Error("Info message should be blocked at default WARN level")
}
// Test that warning messages are allowed
buf.Reset()
logger.Warn(TestLoggerWarnMsg)
if !strings.Contains(buf.String(), TestLoggerWarnMsg) {
t.Error("Warn message should be allowed at default WARN level")
}
}
func TestLogServiceSetLevel(t *testing.T) {
tests := []struct {
name string
setLevel LogLevel
logFunc func(Logger)
expected bool
}{
{
name: "set debug level allows debug",
setLevel: LogLevelDebug,
logFunc: func(l Logger) {
l.Debug(TestLoggerDebugMsg)
},
expected: true,
},
{
name: "set info level blocks debug",
setLevel: LogLevelInfo,
logFunc: func(l Logger) {
l.Debug(TestLoggerDebugMsg)
},
expected: false,
},
{
name: "set warn level blocks info",
setLevel: LogLevelWarn,
logFunc: func(l Logger) {
l.Info(TestLoggerInfoMsg)
},
expected: false,
},
{
name: "set error level blocks warn",
setLevel: LogLevelError,
logFunc: func(l Logger) {
l.Warn(TestLoggerWarnMsg)
},
expected: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
var buf bytes.Buffer
logger := GetLogger()
logger.SetOutput(&buf)
logger.SetLevel(tt.setLevel)
tt.logFunc(logger)
output := buf.String()
hasOutput := len(strings.TrimSpace(output)) > 0
if hasOutput != tt.expected {
t.Errorf("Expected output: %v, got output: %v, level: %v", tt.expected, hasOutput, tt.setLevel)
}
},
)
}
}
// Benchmark tests.
func BenchmarkLoggerInfo(b *testing.B) {
logger := GetLogger()
logger.SetOutput(io.Discard)
logger.SetLevel(LogLevelInfo)
b.ResetTimer()
for i := 0; i < b.N; i++ {
logger.Info("benchmark message")
}
}
func BenchmarkLoggerWithFields(b *testing.B) {
logger := GetLogger()
logger.SetOutput(io.Discard)
logger.SetLevel(LogLevelInfo)
fields := map[string]any{
"component": "benchmark",
"iteration": 0,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
fields["iteration"] = i
logger.WithFields(fields).Info("benchmark message")
}
}

217
shared/paths.go Normal file
View File

@@ -0,0 +1,217 @@
// Package shared provides common utility functions.
package shared
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// AbsolutePath returns the absolute path for the given path.
// It wraps filepath.Abs with consistent error handling.
func AbsolutePath(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
return abs, nil
}
// BaseName returns the base name for the given path, handling special cases.
func BaseName(absPath string) string {
baseName := filepath.Base(absPath)
if baseName == "." || baseName == "" {
return "output"
}
return baseName
}
// ValidateSourcePath validates a source directory path for security.
// It ensures the path exists, is a directory, and doesn't contain path traversal attempts.
func ValidateSourcePath(path string) error {
if path == "" {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationRequired,
TestMsgSourcePath+" is required",
"",
nil,
)
}
// Check for path traversal patterns before cleaning
if strings.Contains(path, "..") {
return NewStructuredError(
ErrorTypeValidation, CodeValidationPath,
"path traversal attempt detected in "+TestMsgSourcePath, path, map[string]any{
"original_path": path,
},
)
}
// Clean and get absolute path
cleaned := filepath.Clean(path)
abs, err := filepath.Abs(cleaned)
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem, CodeFSPathResolution, "cannot resolve "+TestMsgSourcePath, path, map[string]any{
"error": err.Error(),
},
)
}
// Get current working directory to ensure we're not escaping it for relative paths
if !filepath.IsAbs(path) {
cwd, err := os.Getwd()
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem, CodeFSPathResolution, "cannot get current working directory", path, map[string]any{
"error": err.Error(),
},
)
}
// Ensure the resolved path is within or below the current working directory
cwdAbs, err := filepath.Abs(cwd)
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem, CodeFSPathResolution,
"cannot resolve current working directory", path, map[string]any{
"error": err.Error(),
},
)
}
// Check if the absolute path tries to escape the current working directory
if !strings.HasPrefix(abs, cwdAbs) {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"source path attempts to access directories outside current working directory",
path,
map[string]any{
"resolved_path": abs,
"working_dir": cwdAbs,
},
)
}
}
// Check if path exists and is a directory
info, err := os.Stat(cleaned)
if err != nil {
if os.IsNotExist(err) {
return NewStructuredError(ErrorTypeFileSystem, CodeFSNotFound, "source directory does not exist", path, nil)
}
return NewStructuredError(
ErrorTypeFileSystem, CodeFSAccess, "cannot access source directory", path, map[string]any{
"error": err.Error(),
},
)
}
if !info.IsDir() {
return NewStructuredError(
ErrorTypeValidation, CodeValidationPath, "source path must be a directory", path, map[string]any{
"is_file": true,
},
)
}
return nil
}
// ValidateDestinationPath validates a destination file path for security.
// It ensures the path doesn't contain path traversal attempts and the parent directory exists.
func ValidateDestinationPath(path string) error {
if path == "" {
return NewStructuredError(ErrorTypeValidation, CodeValidationRequired, "destination path is required", "", nil)
}
// Check for path traversal patterns before cleaning
if strings.Contains(path, "..") {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"path traversal attempt detected in destination path",
path,
map[string]any{
"original_path": path,
},
)
}
// Clean and validate the path
cleaned := filepath.Clean(path)
// Get absolute path to ensure it's not trying to escape current working directory
abs, err := filepath.Abs(cleaned)
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem, CodeFSPathResolution, "cannot resolve destination path", path, map[string]any{
"error": err.Error(),
},
)
}
// Ensure the destination is not a directory
if info, err := os.Stat(abs); err == nil && info.IsDir() {
return NewStructuredError(
ErrorTypeValidation, CodeValidationPath, "destination cannot be a directory", path, map[string]any{
"is_directory": true,
},
)
}
// Check if parent directory exists and is writable
parentDir := filepath.Dir(abs)
if parentInfo, err := os.Stat(parentDir); err != nil {
if os.IsNotExist(err) {
return NewStructuredError(
ErrorTypeFileSystem, CodeFSNotFound,
"destination parent directory does not exist", path, map[string]any{
"parent_dir": parentDir,
},
)
}
return NewStructuredError(
ErrorTypeFileSystem, CodeFSAccess, "cannot access destination parent directory", path, map[string]any{
"parent_dir": parentDir,
"error": err.Error(),
},
)
} else if !parentInfo.IsDir() {
return NewStructuredError(
ErrorTypeValidation, CodeValidationPath, "destination parent is not a directory", path, map[string]any{
"parent_dir": parentDir,
},
)
}
return nil
}
// ValidateConfigPath validates a configuration file path for security.
// It ensures the path doesn't contain path traversal attempts.
func ValidateConfigPath(path string) error {
if path == "" {
return nil // Empty path is allowed for config
}
// Check for path traversal patterns before cleaning
if strings.Contains(path, "..") {
return NewStructuredError(
ErrorTypeValidation, CodeValidationPath,
"path traversal attempt detected in config path", path, map[string]any{
"original_path": path,
},
)
}
return nil
}

788
shared/paths_test.go Normal file
View File

@@ -0,0 +1,788 @@
package shared
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
const (
windowsOS = "windows"
)
// validatePathTestCase represents a test case for path validation functions.
type validatePathTestCase struct {
name string
path string
wantErr bool
errType ErrorType
errCode string
errContains string
}
// validateExpectedError validates expected error structure and content.
func validateExpectedError(t *testing.T, err error, validatorName string, testCase validatePathTestCase) {
t.Helper()
if err == nil {
t.Errorf("%s() expected error, got nil", validatorName)
return
}
var structErr *StructuredError
if !errors.As(err, &structErr) {
t.Errorf("Expected StructuredError, got %T", err)
return
}
if structErr.Type != testCase.errType {
t.Errorf("Expected error type %v, got %v", testCase.errType, structErr.Type)
}
if structErr.Code != testCase.errCode {
t.Errorf("Expected error code %v, got %v", testCase.errCode, structErr.Code)
}
if testCase.errContains != "" && !strings.Contains(err.Error(), testCase.errContains) {
t.Errorf("Error should contain %q, got: %v", testCase.errContains, err.Error())
}
}
// testPathValidation is a helper function to test path validation functions without duplication.
func testPathValidation(
t *testing.T,
validatorName string,
validatorFunc func(string) error,
tests []validatePathTestCase,
) {
t.Helper()
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
err := validatorFunc(tt.path)
if tt.wantErr {
validateExpectedError(t, err, validatorName, tt)
return
}
if err != nil {
t.Errorf("%s() unexpected error: %v", validatorName, err)
}
},
)
}
}
func TestAbsolutePath(t *testing.T) {
// Get current working directory for tests
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
tests := createAbsolutePathTestCases(cwd)
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
verifyAbsolutePathResult(t, tt.path, tt.wantPrefix, tt.wantErr, tt.wantErrMsg, tt.skipWindows)
},
)
}
}
// createAbsolutePathTestCases creates test cases for AbsolutePath.
func createAbsolutePathTestCases(cwd string) []struct {
name string
path string
wantPrefix string
wantErr bool
wantErrMsg string
skipWindows bool
} {
return []struct {
name string
path string
wantPrefix string
wantErr bool
wantErrMsg string
skipWindows bool
}{
{
name: "absolute path unchanged",
path: cwd,
wantPrefix: cwd,
wantErr: false,
},
{
name: "relative path current directory",
path: ".",
wantPrefix: cwd,
wantErr: false,
},
{
name: "relative path parent directory",
path: "..",
wantPrefix: filepath.Dir(cwd),
wantErr: false,
},
{
name: "relative path with file",
path: "test.txt",
wantPrefix: filepath.Join(cwd, "test.txt"),
wantErr: false,
},
{
name: "relative path with subdirectory",
path: "subdir/file.go",
wantPrefix: filepath.Join(cwd, "subdir", "file.go"),
wantErr: false,
},
{
name: TestMsgEmptyPath,
path: "",
wantPrefix: cwd,
wantErr: false,
},
{
name: "path with tilde",
path: "~/test",
wantPrefix: filepath.Join(cwd, "~", "test"),
wantErr: false,
skipWindows: false,
},
{
name: "path with multiple separators",
path: "path//to///file",
wantPrefix: filepath.Join(cwd, "path", "to", "file"),
wantErr: false,
},
{
name: "path with trailing separator",
path: "path/",
wantPrefix: filepath.Join(cwd, "path"),
wantErr: false,
},
}
}
// verifyAbsolutePathResult verifies the result of AbsolutePath.
func verifyAbsolutePathResult(
t *testing.T,
path, wantPrefix string,
wantErr bool,
wantErrMsg string,
skipWindows bool,
) {
t.Helper()
if skipWindows && runtime.GOOS == windowsOS {
t.Skip("Skipping test on Windows")
}
got, err := AbsolutePath(path)
if wantErr {
verifyExpectedError(t, err, wantErrMsg)
return
}
if err != nil {
t.Errorf("AbsolutePath() unexpected error = %v", err)
return
}
//nolint:errcheck // Test helper, error intentionally ignored for testing
verifyAbsolutePathOutput(t, got, wantPrefix)
}
// verifyExpectedError verifies that an expected error occurred.
func verifyExpectedError(t *testing.T, err error, wantErrMsg string) {
t.Helper()
if err == nil {
t.Error("AbsolutePath() error = nil, wantErr true")
return
}
if wantErrMsg != "" && !strings.Contains(err.Error(), wantErrMsg) {
t.Errorf("AbsolutePath() error = %v, want error containing %v", err, wantErrMsg)
}
}
// verifyAbsolutePathOutput verifies the output of AbsolutePath.
func verifyAbsolutePathOutput(t *testing.T, got, wantPrefix string) {
t.Helper()
// Clean the expected path for comparison
wantClean := filepath.Clean(wantPrefix)
gotClean := filepath.Clean(got)
if gotClean != wantClean {
t.Errorf("AbsolutePath() = %v, want %v", gotClean, wantClean)
}
// Verify the result is actually absolute
if !filepath.IsAbs(got) {
t.Errorf("AbsolutePath() returned non-absolute path: %v", got)
}
}
func TestAbsolutePathSpecialCases(t *testing.T) {
if runtime.GOOS == windowsOS {
t.Skip("Skipping Unix-specific tests on Windows")
}
tests := []struct {
name string
setup func(*testing.T) (string, func())
path string
wantErr bool
}{
{
name: "symlink to directory",
setup: setupSymlinkToDirectory,
path: "",
wantErr: false,
},
{
name: "broken symlink",
setup: setupBrokenSymlink,
path: "",
wantErr: false, // filepath.Abs still works with broken symlinks
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
verifySpecialCaseAbsolutePath(t, tt.setup, tt.path, tt.wantErr)
},
)
}
}
// setupSymlinkToDirectory creates a symlink pointing to a directory.
func setupSymlinkToDirectory(t *testing.T) (string, func()) {
t.Helper()
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "target")
link := filepath.Join(tmpDir, "link")
if err := os.Mkdir(target, 0o750); err != nil {
t.Fatalf("Failed to create target directory: %v", err)
}
if err := os.Symlink(target, link); err != nil {
t.Fatalf("Failed to create symlink: %v", err)
}
return link, func() {
// Cleanup handled automatically by t.TempDir()
}
}
// setupBrokenSymlink creates a broken symlink.
func setupBrokenSymlink(t *testing.T) (string, func()) {
t.Helper()
tmpDir := t.TempDir()
link := filepath.Join(tmpDir, "broken_link")
if err := os.Symlink("/nonexistent/path", link); err != nil {
t.Fatalf("Failed to create broken symlink: %v", err)
}
return link, func() {
// Cleanup handled automatically by t.TempDir()
}
}
// verifySpecialCaseAbsolutePath verifies AbsolutePath with special cases.
func verifySpecialCaseAbsolutePath(t *testing.T, setup func(*testing.T) (string, func()), path string, wantErr bool) {
t.Helper()
testPath, cleanup := setup(t)
//nolint:errcheck // Test helper, error intentionally ignored for testing
defer cleanup()
if path == "" {
path = testPath
}
got, err := AbsolutePath(path)
if (err != nil) != wantErr {
t.Errorf("AbsolutePath() error = %v, wantErr %v", err, wantErr)
return
}
if err == nil && !filepath.IsAbs(got) {
t.Errorf("AbsolutePath() returned non-absolute path: %v", got)
}
}
func TestAbsolutePathConcurrency(_ *testing.T) {
// Test that AbsolutePath is safe for concurrent use
paths := []string{".", "..", "test.go", "subdir/file.txt", "/tmp/test"}
done := make(chan bool)
for _, p := range paths {
go func(path string) {
_, _ = AbsolutePath(path) //nolint:errcheck // Testing concurrency safety only, result not needed
done <- true
}(p)
}
// Wait for all goroutines to complete
for range paths {
<-done
}
}
func TestAbsolutePathErrorFormatting(t *testing.T) {
// This test verifies error message formatting
// We need to trigger an actual error from filepath.Abs
// On Unix systems, we can't easily trigger filepath.Abs errors
// so we'll just verify the error wrapping works correctly
// Create a test that would fail if filepath.Abs returns an error
path := "test/path"
got, err := AbsolutePath(path)
if err != nil {
// If we somehow get an error, verify it's properly formatted
if !strings.Contains(err.Error(), "failed to get absolute path for") {
t.Errorf("Error message format incorrect: %v", err)
}
if !strings.Contains(err.Error(), path) {
t.Errorf("Error message should contain original path: %v", err)
}
} else if !filepath.IsAbs(got) {
// Normal case - just verify we got a valid absolute path
t.Errorf("Expected absolute path, got: %v", got)
}
}
// BenchmarkAbsolutePath benchmarks the AbsolutePath function.
func BenchmarkAbsolutePath(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath("test/path/file.go") //nolint:errcheck // Benchmark test, result not needed
}
}
// BenchmarkAbsolutePathAbs benchmarks with already absolute path.
func BenchmarkAbsolutePathAbs(b *testing.B) {
absPath := "/home/user/test/file.go"
if runtime.GOOS == windowsOS {
absPath = "C:\\Users\\test\\file.go"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath(absPath) //nolint:errcheck // Benchmark test, result not needed
}
}
// BenchmarkAbsolutePathCurrent benchmarks with current directory.
func BenchmarkAbsolutePathCurrent(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = AbsolutePath(".") //nolint:errcheck // Benchmark test, result not needed
}
}
func TestValidateSourcePath(t *testing.T) {
// Create test directories for validation
tmpDir := t.TempDir()
validDir := filepath.Join(tmpDir, "validdir")
validFile := filepath.Join(tmpDir, "validfile.txt")
// Create test directory and file
if err := os.Mkdir(validDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
if err := os.WriteFile(validFile, []byte("test"), 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []validatePathTestCase{
{
name: TestMsgEmptyPath,
path: "",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationRequired,
errContains: "source path is required",
},
{
name: "path traversal with double dots",
path: TestPathEtcPasswdTraversal,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "path traversal in middle",
path: "valid/../../../secrets",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "nonexistent directory",
path: "/nonexistent/directory",
wantErr: true,
errType: ErrorTypeFileSystem,
errCode: CodeFSNotFound,
errContains: "source directory does not exist",
},
{
name: "file instead of directory",
path: validFile,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: "source path must be a directory",
},
{
name: "valid directory (absolute)",
path: validDir,
wantErr: false,
},
{
name: "valid directory (relative)",
path: ".",
wantErr: false,
},
{
name: "valid directory (current)",
path: tmpDir,
wantErr: false,
},
}
// Save and restore current directory for relative path tests
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
defer func() {
// Need to use os.Chdir here since t.Chdir only works in the current function context
if err := os.Chdir(originalWd); err != nil { // nolint:usetesting // needed in defer function
t.Logf("Failed to restore working directory: %v", err)
}
}()
t.Chdir(tmpDir)
testPathValidation(t, "ValidateSourcePath", ValidateSourcePath, tests)
}
func TestValidateDestinationPath(t *testing.T) {
tmpDir := t.TempDir()
existingDir := filepath.Join(tmpDir, "existing")
existingFile := filepath.Join(tmpDir, "existing.txt")
validDest := filepath.Join(tmpDir, TestFileOutputTXT)
// Create test directory and file
if err := os.Mkdir(existingDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
if err := os.WriteFile(existingFile, []byte("test"), 0o600); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
tests := []validatePathTestCase{
{
name: TestMsgEmptyPath,
path: "",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationRequired,
errContains: "destination path is required",
},
{
name: "path traversal attack",
path: "../../../tmp/malicious.txt",
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "destination is existing directory",
path: existingDir,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: "destination cannot be a directory",
},
{
name: "parent directory doesn't exist",
path: "/nonexistent/dir/TestFileOutputTXT",
wantErr: true,
errType: ErrorTypeFileSystem,
errCode: CodeFSNotFound,
errContains: "destination parent directory does not exist",
},
{
name: "valid destination path",
path: validDest,
wantErr: false,
},
{
name: "overwrite existing file (should be valid)",
path: existingFile,
wantErr: false,
},
}
testPathValidation(t, "ValidateDestinationPath", ValidateDestinationPath, tests)
}
func TestValidateConfigPath(t *testing.T) {
tests := []validatePathTestCase{
{
name: "empty path (allowed for config)",
path: "",
wantErr: false,
},
{
name: "path traversal attack",
path: TestPathEtcPasswdTraversal,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "complex path traversal",
path: "config/../../../secrets/" + TestFileConfigYAML,
wantErr: true,
errType: ErrorTypeValidation,
errCode: CodeValidationPath,
errContains: TestMsgPathTraversalAttempt,
},
{
name: "valid config path",
path: TestFileConfigYAML,
wantErr: false,
},
{
name: "valid absolute config path",
path: "/etc/myapp/" + TestFileConfigYAML,
wantErr: false,
},
{
name: "config in subdirectory",
path: "configs/production.yaml",
wantErr: false,
},
}
testPathValidation(t, "ValidateConfigPath", ValidateConfigPath, tests)
}
func TestBaseName(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{
name: "simple filename",
path: "/path/to/file.txt",
expected: "file.txt",
},
{
name: "directory path",
path: "/path/to/directory",
expected: "directory",
},
{
name: "root path",
path: "/",
expected: "/",
},
{
name: "current directory",
path: ".",
expected: "output", // Special case: . returns "output"
},
{
name: TestMsgEmptyPath,
path: "",
expected: "output", // Special case: empty returns "output"
},
{
name: "path with trailing separator",
path: "/path/to/dir/",
expected: "dir",
},
{
name: "relative path",
path: "subdir/file.go",
expected: "file.go",
},
{
name: "single filename",
path: "README.md",
expected: "README.md",
},
{
name: "path with spaces",
path: "/path/to/my file.txt",
expected: "my file.txt",
},
{
name: "path with special characters",
path: "/path/to/file-name_123.ext",
expected: "file-name_123.ext",
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := BaseName(tt.path)
if result != tt.expected {
t.Errorf("BaseName(%q) = %q, want %q", tt.path, result, tt.expected)
}
},
)
}
}
// Security-focused integration tests.
func TestPathValidationIntegration(t *testing.T) {
tmpDir := t.TempDir()
validSourceDir := filepath.Join(tmpDir, "source")
validDestFile := filepath.Join(tmpDir, TestFileOutputTXT)
// Create source directory
if err := os.Mkdir(validSourceDir, 0o750); err != nil {
t.Fatalf(TestMsgFailedToCreateTestDir, err)
}
// Test complete validation workflow
tests := []struct {
name string
sourcePath string
destPath string
configPath string
expectSourceErr bool
expectDestErr bool
expectConfigErr bool
}{
{
name: "valid paths",
sourcePath: validSourceDir,
destPath: validDestFile,
configPath: TestFileConfigYAML,
expectSourceErr: false,
expectDestErr: false,
expectConfigErr: false,
},
{
name: "source path traversal attack",
sourcePath: "../../../etc",
destPath: validDestFile,
configPath: TestFileConfigYAML,
expectSourceErr: true,
expectDestErr: false,
expectConfigErr: false,
},
{
name: "destination path traversal attack",
sourcePath: validSourceDir,
destPath: "../../../tmp/malicious.txt",
configPath: TestFileConfigYAML,
expectSourceErr: false,
expectDestErr: true,
expectConfigErr: false,
},
{
name: "config path traversal attack",
sourcePath: validSourceDir,
destPath: validDestFile,
configPath: TestPathEtcPasswdTraversal,
expectSourceErr: false,
expectDestErr: false,
expectConfigErr: true,
},
{
name: "multiple path traversal attacks",
sourcePath: "../../../var",
destPath: "../../../tmp/bad.txt",
configPath: "../../../etc/config",
expectSourceErr: true,
expectDestErr: true,
expectConfigErr: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
// Test source validation
sourceErr := ValidateSourcePath(tt.sourcePath)
if (sourceErr != nil) != tt.expectSourceErr {
t.Errorf("Source validation: expected error %v, got %v", tt.expectSourceErr, sourceErr)
}
// Test destination validation
destErr := ValidateDestinationPath(tt.destPath)
if (destErr != nil) != tt.expectDestErr {
t.Errorf("Destination validation: expected error %v, got %v", tt.expectDestErr, destErr)
}
// Test config validation
configErr := ValidateConfigPath(tt.configPath)
if (configErr != nil) != tt.expectConfigErr {
t.Errorf("Config validation: expected error %v, got %v", tt.expectConfigErr, configErr)
}
},
)
}
}
// Benchmark the validation functions for performance.
func BenchmarkValidateSourcePath(b *testing.B) {
tmpDir := b.TempDir()
validDir := filepath.Join(tmpDir, "testdir")
if err := os.Mkdir(validDir, 0o750); err != nil {
b.Fatalf(TestMsgFailedToCreateTestDir, err)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ValidateSourcePath(validDir) // nolint:errcheck // benchmark test
}
}
func BenchmarkValidateDestinationPath(b *testing.B) {
tmpDir := b.TempDir()
validDest := filepath.Join(tmpDir, TestFileOutputTXT)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ValidateDestinationPath(validDest) // nolint:errcheck // benchmark test
}
}
func BenchmarkBaseName(b *testing.B) {
path := "/very/long/path/to/some/deeply/nested/file.txt"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = BaseName(path)
}
}

201
shared/writers.go Normal file
View File

@@ -0,0 +1,201 @@
// Package shared provides common utility functions.
package shared
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
)
// SafeCloseReader safely closes a reader if it implements io.Closer.
// This eliminates the duplicated closeReader methods across all writers.
func SafeCloseReader(reader io.Reader, path string) {
if closer, ok := reader.(io.Closer); ok {
if err := closer.Close(); err != nil {
LogError(
"Failed to close file reader",
WrapError(err, ErrorTypeIO, CodeIOClose, "failed to close file reader").WithFilePath(path),
)
}
}
}
// WriteWithErrorWrap performs file writing with consistent error handling.
// This centralizes the common pattern of writing strings with error wrapping.
func WriteWithErrorWrap(writer io.Writer, content, errorMsg, filePath string) error {
if _, err := writer.Write([]byte(content)); err != nil {
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOWrite, errorMsg)
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
return nil
}
// StreamContent provides a common streaming implementation with chunk processing.
// This eliminates the similar streaming patterns across JSON and Markdown writers.
func StreamContent(
reader io.Reader,
writer io.Writer,
chunkSize int,
filePath string,
processChunk func([]byte) []byte,
) error {
buf := make([]byte, chunkSize)
for {
n, err := reader.Read(buf)
if n > 0 {
if err := writeProcessedChunk(writer, buf[:n], filePath, processChunk); err != nil {
return err
}
}
if err == io.EOF {
break
}
if err != nil {
return wrapReadError(err, filePath)
}
}
return nil
}
// writeProcessedChunk processes and writes a chunk of data.
func writeProcessedChunk(writer io.Writer, chunk []byte, filePath string, processChunk func([]byte) []byte) error {
processed := chunk
if processChunk != nil {
processed = processChunk(processed)
}
if _, writeErr := writer.Write(processed); writeErr != nil {
return wrapWriteError(writeErr, filePath)
}
return nil
}
// wrapWriteError wraps a write error with context.
func wrapWriteError(err error, filePath string) error {
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOWrite, "failed to write content chunk")
if filePath != "" {
//nolint:errcheck // WithFilePath error doesn't affect wrapped error integrity
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
// wrapReadError wraps a read error with context.
func wrapReadError(err error, filePath string) error {
wrappedErr := WrapError(err, ErrorTypeIO, CodeIORead, "failed to read content chunk")
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
// EscapeForJSON escapes content for JSON output using the standard library.
// This replaces the custom escapeJSONString function with a more robust implementation.
func EscapeForJSON(content string) string {
// Use the standard library's JSON marshaling for proper escaping
jsonBytes, err := json.Marshal(content)
if err != nil {
// If marshaling fails (which is very unlikely for a string), return the original
return content
}
// Remove the surrounding quotes that json.Marshal adds
jsonStr := string(jsonBytes)
if len(jsonStr) >= 2 && jsonStr[0] == '"' && jsonStr[len(jsonStr)-1] == '"' {
return jsonStr[1 : len(jsonStr)-1]
}
return jsonStr
}
// EscapeForYAML quotes/escapes content for YAML output if needed.
// This centralizes the YAML string quoting logic.
func EscapeForYAML(content string) string {
// Quote if contains special characters, spaces, or starts with special chars
needsQuotes := strings.ContainsAny(content, " \t\n\r:{}[]|>-'\"\\") ||
strings.HasPrefix(content, "-") ||
strings.HasPrefix(content, "?") ||
strings.HasPrefix(content, ":") ||
content == "" ||
content == LiteralTrue || content == LiteralFalse ||
content == LiteralNull || content == "~"
if needsQuotes {
// Use double quotes and escape internal quotes
escaped := strings.ReplaceAll(content, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
return "\"" + escaped + "\""
}
return content
}
// CheckContextCancellation is a helper function that checks if context is canceled and returns appropriate error.
func CheckContextCancellation(ctx context.Context, operation string) error {
select {
case <-ctx.Done():
return fmt.Errorf("%s canceled: %w", operation, ctx.Err())
default:
return nil
}
}
// WithContextCheck wraps an operation with context cancellation checking.
func WithContextCheck(ctx context.Context, operation string, fn func() error) error {
if err := CheckContextCancellation(ctx, operation); err != nil {
return err
}
return fn()
}
// StreamLines provides line-based streaming for YAML content.
// This provides an alternative streaming approach for YAML writers.
func StreamLines(reader io.Reader, writer io.Writer, filePath string, lineProcessor func(string) string) error {
// Read all content first (for small files this is fine)
content, err := io.ReadAll(reader)
if err != nil {
wrappedErr := WrapError(err, ErrorTypeIO, CodeIORead, "failed to read content for line processing")
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
// Split into lines and process each
lines := strings.Split(string(content), "\n")
for i, line := range lines {
processedLine := line
if lineProcessor != nil {
processedLine = lineProcessor(line)
}
// Write line with proper line ending (except for last empty line)
lineToWrite := processedLine
if i < len(lines)-1 || line != "" {
lineToWrite += "\n"
}
if _, writeErr := writer.Write([]byte(lineToWrite)); writeErr != nil {
wrappedErr := WrapError(writeErr, ErrorTypeIO, CodeIOWrite, "failed to write processed line")
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
}
return nil
}

1038
shared/writers_test.go Normal file

File diff suppressed because it is too large Load Diff