mirror of
https://github.com/ivuorinen/gibidify.git
synced 2026-02-06 23:46:46 +00:00
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:
803
shared/constants.go
Normal file
803
shared/constants.go
Normal 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
74
shared/conversions.go
Normal 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
321
shared/conversions_test.go
Normal 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
259
shared/errors.go
Normal 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
932
shared/errors_test.go
Normal 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
164
shared/logger.go
Normal 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
376
shared/logger_test.go
Normal 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
217
shared/paths.go
Normal 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
788
shared/paths_test.go
Normal 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
201
shared/writers.go
Normal 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
1038
shared/writers_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user