feat: update go to 1.25, add permissions and envs (#49)

* chore(ci): update go to 1.25, add permissions and envs
* fix(ci): update pr-lint.yml
* chore: update go, fix linting
* fix: tests and linting
* fix(lint): lint fixes, renovate should now pass
* fix: updates, security upgrades
* chore: workflow updates, lint
* fix: more lint, checkmake, and other fixes
* fix: more lint, convert scripts to POSIX compliant
* fix: simplify codeql workflow
* tests: increase test coverage, fix found issues
* fix(lint): editorconfig checking, add to linters
* fix(lint): shellcheck, add to linters
* fix(lint): apply cr comment suggestions
* fix(ci): remove step-security/harden-runner
* fix(lint): remove duplication, apply cr fixes
* fix(ci): tests in CI/CD pipeline
* chore(lint): deduplication of strings
* fix(lint): apply cr comment suggestions
* fix(ci): actionlint
* fix(lint): apply cr comment suggestions
* chore: lint, add deps management
This commit is contained in:
2025-10-10 12:14:42 +03:00
committed by GitHub
parent 958f5952a0
commit 3f65b813bd
100 changed files with 6997 additions and 1225 deletions

283
gibidiutils/errors.go Normal file
View File

@@ -0,0 +1,283 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"errors"
"fmt"
"sort"
"strings"
"github.com/sirupsen/logrus"
)
// 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"
}
}
// Error formatting templates.
const (
errorFormatWithCause = "%s: %v"
)
// 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 {
base := fmt.Sprintf("%s [%s]: %s", e.Type, e.Code, e.Message)
if len(e.Context) > 0 {
// Sort keys for deterministic output
keys := make([]string, 0, len(e.Context))
for k := range e.Context {
keys = append(keys, k)
}
sort.Strings(keys)
ctxPairs := make([]string, 0, len(e.Context))
for _, k := range keys {
ctxPairs = append(ctxPairs, fmt.Sprintf("%s=%v", k, e.Context[k]))
}
base = fmt.Sprintf("%s | context: %s", base, strings.Join(ctxPairs, ", "))
}
if e.Cause != nil {
return fmt.Sprintf(errorFormatWithCause, base, e.Cause)
}
return base
}
// 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 (
// CLI Error Codes
CodeCLIMissingSource = "MISSING_SOURCE"
CodeCLIInvalidArgs = "INVALID_ARGS"
// FileSystem Error Codes
CodeFSPathResolution = "PATH_RESOLUTION"
CodeFSPermission = "PERMISSION_DENIED"
CodeFSNotFound = "NOT_FOUND"
CodeFSAccess = "ACCESS_DENIED"
// Processing Error Codes
CodeProcessingFileRead = "FILE_READ"
CodeProcessingCollection = "COLLECTION"
CodeProcessingTraversal = "TRAVERSAL"
CodeProcessingEncode = "ENCODE"
// Configuration Error Codes
CodeConfigValidation = "VALIDATION"
CodeConfigMissing = "MISSING"
// IO Error Codes
CodeIOFileCreate = "FILE_CREATE"
CodeIOFileWrite = "FILE_WRITE"
CodeIOEncoding = "ENCODING"
CodeIOWrite = "WRITE"
CodeIOFileRead = "FILE_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]",
"",
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...)
}
// Check if it's a structured error and log with additional context
var structErr *StructuredError
if errors.As(err, &structErr) {
logrus.WithFields(logrus.Fields{
"error_type": structErr.Type.String(),
"error_code": structErr.Code,
"context": structErr.Context,
"file_path": structErr.FilePath,
"line": structErr.Line,
}).Errorf(errorFormatWithCause, msg, err)
} else {
// Log regular errors without structured fields
logrus.Errorf(errorFormatWithCause, 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...)
}
}

View File

@@ -0,0 +1,367 @@
package gibidiutils
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
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",
},
{
name: "Invalid error type",
errType: ErrorType(999),
expected: "Unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.errType.String()
assert.Equal(t, tt.expected, result)
})
}
}
func TestStructuredErrorMethods(t *testing.T) {
t.Run("Error method", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeValidation,
Code: CodeValidationRequired,
Message: "field is required",
}
expected := "Validation [REQUIRED]: field is required"
assert.Equal(t, expected, err.Error())
})
t.Run("Error method with context", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeFileSystem,
Code: CodeFSNotFound,
Message: testErrFileNotFound,
Context: map[string]interface{}{
"path": "/test/file.txt",
},
}
errStr := err.Error()
assert.Contains(t, errStr, "FileSystem")
assert.Contains(t, errStr, "NOT_FOUND")
assert.Contains(t, errStr, testErrFileNotFound)
assert.Contains(t, errStr, "/test/file.txt")
assert.Contains(t, errStr, "path")
})
t.Run("Unwrap method", func(t *testing.T) {
innerErr := errors.New("inner error")
err := &StructuredError{
Type: ErrorTypeIO,
Code: CodeIOFileWrite,
Message: testErrWriteFailed,
Cause: innerErr,
}
assert.Equal(t, innerErr, err.Unwrap())
})
t.Run("Unwrap with nil cause", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeIO,
Code: CodeIOFileWrite,
Message: testErrWriteFailed,
}
assert.Nil(t, err.Unwrap())
})
}
func TestWithContextMethods(t *testing.T) {
t.Run("WithContext", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeValidation,
Code: CodeValidationFormat,
Message: testErrInvalidFormat,
}
err = err.WithContext("format", "xml")
err = err.WithContext("expected", "json")
assert.NotNil(t, err.Context)
assert.Equal(t, "xml", err.Context["format"])
assert.Equal(t, "json", err.Context["expected"])
})
t.Run("WithFilePath", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeFileSystem,
Code: CodeFSPermission,
Message: "permission denied",
}
err = err.WithFilePath("/etc/passwd")
assert.Equal(t, "/etc/passwd", err.FilePath)
})
t.Run("WithLine", func(t *testing.T) {
err := &StructuredError{
Type: ErrorTypeProcessing,
Code: CodeProcessingFileRead,
Message: "read error",
}
err = err.WithLine(42)
assert.Equal(t, 42, err.Line)
})
}
func TestNewStructuredError(t *testing.T) {
tests := []struct {
name string
errType ErrorType
code string
message string
filePath string
context map[string]interface{}
}{
{
name: "basic error",
errType: ErrorTypeValidation,
code: CodeValidationRequired,
message: "field is required",
filePath: "",
context: nil,
},
{
name: "error with file path",
errType: ErrorTypeFileSystem,
code: CodeFSNotFound,
message: testErrFileNotFound,
filePath: "/test/missing.txt",
context: nil,
},
{
name: "error with context",
errType: ErrorTypeIO,
code: CodeIOFileWrite,
message: testErrWriteFailed,
context: map[string]interface{}{
"size": 1024,
"error": "disk full",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := NewStructuredError(tt.errType, tt.code, tt.message, tt.filePath, tt.context)
assert.NotNil(t, err)
assert.Equal(t, tt.errType, err.Type)
assert.Equal(t, tt.code, err.Code)
assert.Equal(t, tt.message, err.Message)
assert.Equal(t, tt.filePath, err.FilePath)
assert.Equal(t, tt.context, err.Context)
})
}
}
func TestNewStructuredErrorf(t *testing.T) {
err := NewStructuredErrorf(
ErrorTypeValidation,
CodeValidationSize,
"file size %d exceeds limit %d",
2048, 1024,
)
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeValidation, err.Type)
assert.Equal(t, CodeValidationSize, err.Code)
assert.Equal(t, "file size 2048 exceeds limit 1024", err.Message)
}
func TestWrapError(t *testing.T) {
innerErr := errors.New("original error")
wrappedErr := WrapError(
innerErr,
ErrorTypeProcessing,
CodeProcessingFileRead,
"failed to process file",
)
assert.NotNil(t, wrappedErr)
assert.Equal(t, ErrorTypeProcessing, wrappedErr.Type)
assert.Equal(t, CodeProcessingFileRead, wrappedErr.Code)
assert.Equal(t, "failed to process file", wrappedErr.Message)
assert.Equal(t, innerErr, wrappedErr.Cause)
}
func TestWrapErrorf(t *testing.T) {
innerErr := errors.New("original error")
wrappedErr := WrapErrorf(
innerErr,
ErrorTypeIO,
CodeIOFileCreate,
"failed to create %s in %s",
"output.txt", "/tmp",
)
assert.NotNil(t, wrappedErr)
assert.Equal(t, ErrorTypeIO, wrappedErr.Type)
assert.Equal(t, CodeIOFileCreate, wrappedErr.Code)
assert.Equal(t, "failed to create output.txt in /tmp", wrappedErr.Message)
assert.Equal(t, innerErr, wrappedErr.Cause)
}
func TestSpecificErrorConstructors(t *testing.T) {
t.Run("NewMissingSourceError", func(t *testing.T) {
err := NewMissingSourceError()
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeCLI, err.Type)
assert.Equal(t, CodeCLIMissingSource, err.Code)
assert.Contains(t, err.Message, "source")
})
t.Run("NewFileSystemError", func(t *testing.T) {
err := NewFileSystemError(CodeFSPermission, "access denied")
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeFileSystem, err.Type)
assert.Equal(t, CodeFSPermission, err.Code)
assert.Equal(t, "access denied", err.Message)
})
t.Run("NewProcessingError", func(t *testing.T) {
err := NewProcessingError(CodeProcessingCollection, "collection failed")
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeProcessing, err.Type)
assert.Equal(t, CodeProcessingCollection, err.Code)
assert.Equal(t, "collection failed", err.Message)
})
t.Run("NewIOError", func(t *testing.T) {
err := NewIOError(CodeIOFileWrite, testErrWriteFailed)
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeIO, err.Type)
assert.Equal(t, CodeIOFileWrite, err.Code)
assert.Equal(t, testErrWriteFailed, err.Message)
})
t.Run("NewValidationError", func(t *testing.T) {
err := NewValidationError(CodeValidationFormat, testErrInvalidFormat)
assert.NotNil(t, err)
assert.Equal(t, ErrorTypeValidation, err.Type)
assert.Equal(t, CodeValidationFormat, err.Code)
assert.Equal(t, testErrInvalidFormat, err.Message)
})
}
// TestLogErrorf is already covered in errors_test.go
func TestStructuredErrorChaining(t *testing.T) {
// Test method chaining
err := NewStructuredError(
ErrorTypeFileSystem,
CodeFSNotFound,
testErrFileNotFound,
"",
nil,
).WithFilePath("/test.txt").WithLine(10).WithContext("operation", "read")
assert.Equal(t, "/test.txt", err.FilePath)
assert.Equal(t, 10, err.Line)
assert.Equal(t, "read", err.Context["operation"])
}
func TestErrorCodes(t *testing.T) {
// Test that all error codes are defined
codes := []string{
CodeCLIMissingSource,
CodeCLIInvalidArgs,
CodeFSPathResolution,
CodeFSPermission,
CodeFSNotFound,
CodeFSAccess,
CodeProcessingFileRead,
CodeProcessingCollection,
CodeProcessingTraversal,
CodeProcessingEncode,
CodeConfigValidation,
CodeConfigMissing,
CodeIOFileCreate,
CodeIOFileWrite,
CodeIOEncoding,
CodeIOWrite,
CodeIOFileRead,
CodeIOClose,
CodeValidationRequired,
CodeValidationFormat,
CodeValidationSize,
CodeValidationPath,
CodeResourceLimitFiles,
CodeResourceLimitTotalSize,
CodeResourceLimitMemory,
CodeResourceLimitTimeout,
}
// All codes should be non-empty strings
for _, code := range codes {
assert.NotEmpty(t, code, "Error code should not be empty")
assert.NotEqual(t, "", code, "Error code should be defined")
}
}
func TestErrorUnwrapChain(t *testing.T) {
// Test unwrapping through multiple levels
innermost := errors.New("innermost error")
middle := WrapError(innermost, ErrorTypeIO, CodeIOFileRead, "read failed")
outer := WrapError(middle, ErrorTypeProcessing, CodeProcessingFileRead, "processing failed")
// Test unwrapping
assert.Equal(t, middle, outer.Unwrap())
assert.Equal(t, innermost, middle.Unwrap())
// innermost is a plain error, doesn't have Unwrap() method
// No need to test it
// Test error chain messages
assert.Contains(t, outer.Error(), "Processing")
assert.Contains(t, middle.Error(), "IO")
}

243
gibidiutils/errors_test.go Normal file
View File

@@ -0,0 +1,243 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
"github.com/sirupsen/logrus"
)
// captureLogOutput captures logrus output for testing
func captureLogOutput(f func()) string {
var buf bytes.Buffer
logrus.SetOutput(&buf)
defer logrus.SetOutput(logrus.StandardLogger().Out)
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("disk full"),
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 := 0; i < 10; i++ {
go func(n int) {
LogError("concurrent operation", fmt.Errorf("error %d", n))
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
}
func TestLogErrorfConcurrency(_ *testing.T) {
// Test that LogErrorf is safe for concurrent use
done := make(chan bool)
for i := 0; i < 10; i++ {
go func(n int) {
LogErrorf(fmt.Errorf("error %d", n), "concurrent operation %d", n)
done <- true
}(i)
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
}
// BenchmarkLogError benchmarks the LogError function
func BenchmarkLogError(b *testing.B) {
err := errors.New("benchmark error")
// Disable output during benchmark
logrus.SetOutput(bytes.NewBuffer(nil))
defer logrus.SetOutput(logrus.StandardLogger().Out)
b.ResetTimer()
for i := 0; i < b.N; i++ {
LogError("benchmark operation", err)
}
}
// BenchmarkLogErrorf benchmarks the LogErrorf function
func BenchmarkLogErrorf(b *testing.B) {
err := errors.New("benchmark error")
// Disable output during benchmark
logrus.SetOutput(bytes.NewBuffer(nil))
defer logrus.SetOutput(logrus.StandardLogger().Out)
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)
}
}

10
gibidiutils/icons.go Normal file
View File

@@ -0,0 +1,10 @@
package gibidiutils
// Unicode icons and symbols for CLI UI and test output.
const (
IconSuccess = "✓" // U+2713
IconError = "✗" // U+2717
IconWarning = "⚠" // U+26A0
IconBullet = "•" // U+2022
IconInfo = "" // U+2139 FE0F
)

311
gibidiutils/paths.go Normal file
View File

@@ -0,0 +1,311 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// EscapeForMarkdown sanitizes a string for safe use in Markdown code-fence and header lines.
// It replaces backticks with backslash-escaped backticks and removes/collapses newlines.
func EscapeForMarkdown(s string) string {
// Escape backticks
safe := strings.ReplaceAll(s, "`", "\\`")
// Remove newlines (collapse to space)
safe = strings.ReplaceAll(safe, "\n", " ")
safe = strings.ReplaceAll(safe, "\r", " ")
return safe
}
// GetAbsolutePath returns the absolute path for the given path.
// It wraps filepath.Abs with consistent error handling.
func GetAbsolutePath(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
}
// GetBaseName returns the base name for the given path, handling special cases.
func GetBaseName(absPath string) string {
baseName := filepath.Base(absPath)
if baseName == "." || baseName == "" {
return "output"
}
return baseName
}
// checkPathTraversal checks for path traversal patterns and returns an error if found.
func checkPathTraversal(path, context string) error {
// Normalize separators without cleaning (to preserve ..)
normalized := filepath.ToSlash(path)
// Split into components
components := strings.Split(normalized, "/")
// Check each component for exact ".." match
for _, component := range components {
if component == ".." {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
fmt.Sprintf("path traversal attempt detected in %s", context),
path,
map[string]interface{}{
"original_path": path,
},
)
}
}
return nil
}
// cleanAndResolveAbsPath cleans a path and resolves it to an absolute path.
func cleanAndResolveAbsPath(path, context string) (string, error) {
cleaned := filepath.Clean(path)
abs, err := filepath.Abs(cleaned)
if err != nil {
return "", NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
fmt.Sprintf("cannot resolve %s", context),
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
return abs, nil
}
// evalSymlinksOrStructuredError wraps filepath.EvalSymlinks with structured error handling.
func evalSymlinksOrStructuredError(path, context, original string) (string, error) {
eval, err := filepath.EvalSymlinks(path)
if err != nil {
return "", NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
fmt.Sprintf("cannot resolve symlinks for %s", context),
original,
map[string]interface{}{
"resolved_path": path,
"context": context,
"error": err.Error(),
},
)
}
return eval, nil
}
// validateWorkingDirectoryBoundary checks if the given absolute path escapes the working directory.
func validateWorkingDirectoryBoundary(abs, path string) error {
cwd, err := os.Getwd()
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
"cannot get current working directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
cwdAbs, err := filepath.Abs(cwd)
if err != nil {
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSPathResolution,
"cannot resolve current working directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
absEval, err := evalSymlinksOrStructuredError(abs, "source path", path)
if err != nil {
return err
}
cwdEval, err := evalSymlinksOrStructuredError(cwdAbs, "working directory", path)
if err != nil {
return err
}
rel, err := filepath.Rel(cwdEval, absEval)
if err != nil {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"cannot determine relative path",
path,
map[string]interface{}{
"resolved_path": absEval,
"working_dir": cwdEval,
"error": err.Error(),
},
)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"source path attempts to access directories outside current working directory",
path,
map[string]interface{}{
"resolved_path": absEval,
"working_dir": cwdEval,
"relative_path": rel,
},
)
}
return nil
}
// ValidateSourcePath validates a source directory path for security.
// It ensures the path exists, is a directory, and doesn't contain path traversal attempts.
//
//revive:disable-next-line:function-length
func ValidateSourcePath(path string) error {
if path == "" {
return NewValidationError(CodeValidationRequired, "source path is required")
}
// Check for path traversal patterns before cleaning
if err := checkPathTraversal(path, "source path"); err != nil {
return err
}
// Clean and get absolute path
abs, err := cleanAndResolveAbsPath(path, "source path")
if err != nil {
return err
}
cleaned := filepath.Clean(path)
// Ensure the resolved path is within or below the current working directory for relative paths
if !filepath.IsAbs(path) {
if err := validateWorkingDirectoryBoundary(abs, path); err != nil {
return err
}
}
// Check if path exists and is a directory
info, err := os.Stat(cleaned)
if err != nil {
if os.IsNotExist(err) {
return NewFileSystemError(CodeFSNotFound, "source directory does not exist").WithFilePath(path)
}
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSAccess,
"cannot access source directory",
path,
map[string]interface{}{
"error": err.Error(),
},
)
}
if !info.IsDir() {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"source path must be a directory",
path,
map[string]interface{}{
"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 NewValidationError(CodeValidationRequired, "destination path is required")
}
// Check for path traversal patterns before cleaning
if err := checkPathTraversal(path, "destination path"); err != nil {
return err
}
// Get absolute path to ensure it's not trying to escape current working directory
abs, err := cleanAndResolveAbsPath(path, "destination path")
if err != nil {
return err
}
// 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]interface{}{
"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]interface{}{
"parent_dir": parentDir,
},
)
}
return NewStructuredError(
ErrorTypeFileSystem,
CodeFSAccess,
"cannot access destination parent directory",
path,
map[string]interface{}{
"parent_dir": parentDir,
"error": err.Error(),
},
)
} else if !parentInfo.IsDir() {
return NewStructuredError(
ErrorTypeValidation,
CodeValidationPath,
"destination parent is not a directory",
path,
map[string]interface{}{
"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
return checkPathTraversal(path, "config path")
}

View File

@@ -0,0 +1,368 @@
package gibidiutils
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetBaseName(t *testing.T) {
tests := []struct {
name string
absPath string
expected string
}{
{
name: "normal path",
absPath: "/home/user/project",
expected: "project",
},
{
name: "path with trailing slash",
absPath: "/home/user/project/",
expected: "project",
},
{
name: "root path",
absPath: "/",
expected: "/",
},
{
name: "current directory",
absPath: ".",
expected: "output",
},
{
name: testEmptyPath,
absPath: "",
expected: "output",
},
{
name: "file path",
absPath: "/home/user/file.txt",
expected: "file.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetBaseName(tt.absPath)
assert.Equal(t, tt.expected, result)
})
}
}
func TestValidateSourcePath(t *testing.T) {
// Create a temp directory for testing
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "test.txt")
require.NoError(t, os.WriteFile(tempFile, []byte("test"), 0o600))
tests := []struct {
name string
path string
expectedError string
}{
{
name: testEmptyPath,
path: "",
expectedError: "source path is required",
},
{
name: testPathTraversalAttempt,
path: "../../../etc/passwd",
expectedError: testPathTraversalDetected,
},
{
name: "path with double dots",
path: "/home/../etc/passwd",
expectedError: testPathTraversalDetected,
},
{
name: "non-existent path",
path: "/definitely/does/not/exist",
expectedError: "does not exist",
},
{
name: "file instead of directory",
path: tempFile,
expectedError: "must be a directory",
},
{
name: "valid directory",
path: tempDir,
expectedError: "",
},
{
name: "valid relative path",
path: ".",
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSourcePath(tt.path)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
// Check if it's a StructuredError
var structErr *StructuredError
if errors.As(err, &structErr) {
assert.NotEmpty(t, structErr.Code)
assert.NotEqual(t, ErrorTypeUnknown, structErr.Type)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateDestinationPath(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
path string
expectedError string
}{
{
name: testEmptyPath,
path: "",
expectedError: "destination path is required",
},
{
name: testPathTraversalAttempt,
path: "../../etc/passwd",
expectedError: testPathTraversalDetected,
},
{
name: "absolute path traversal",
path: "/home/../../../etc/passwd",
expectedError: testPathTraversalDetected,
},
{
name: "valid new file",
path: filepath.Join(tempDir, "newfile.txt"),
expectedError: "",
},
{
name: "valid relative path",
path: "output.txt",
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDestinationPath(tt.path)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateConfigPath(t *testing.T) {
tempDir := t.TempDir()
validConfig := filepath.Join(tempDir, "config.yaml")
require.NoError(t, os.WriteFile(validConfig, []byte("key: value"), 0o600))
tests := []struct {
name string
path string
expectedError string
}{
{
name: testEmptyPath,
path: "",
expectedError: "", // Empty config path is allowed
},
{
name: testPathTraversalAttempt,
path: "../../../etc/config.yaml",
expectedError: testPathTraversalDetected,
},
// ValidateConfigPath doesn't check if file exists or is regular file
// It only checks for path traversal
{
name: "valid config file",
path: validConfig,
expectedError: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateConfigPath(tt.path)
if tt.expectedError != "" {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError)
} else {
assert.NoError(t, err)
}
})
}
}
// TestGetAbsolutePath is already covered in paths_test.go
func TestValidationErrorTypes(t *testing.T) {
t.Run("source path validation errors", func(t *testing.T) {
// Test empty source
err := ValidateSourcePath("")
assert.Error(t, err)
var structErrEmptyPath *StructuredError
if errors.As(err, &structErrEmptyPath) {
assert.Equal(t, ErrorTypeValidation, structErrEmptyPath.Type)
assert.Equal(t, CodeValidationRequired, structErrEmptyPath.Code)
}
// Test path traversal
err = ValidateSourcePath("../../../etc")
assert.Error(t, err)
var structErrTraversal *StructuredError
if errors.As(err, &structErrTraversal) {
assert.Equal(t, ErrorTypeValidation, structErrTraversal.Type)
assert.Equal(t, CodeValidationPath, structErrTraversal.Code)
}
})
t.Run("destination path validation errors", func(t *testing.T) {
// Test empty destination
err := ValidateDestinationPath("")
assert.Error(t, err)
var structErrEmptyDest *StructuredError
if errors.As(err, &structErrEmptyDest) {
assert.Equal(t, ErrorTypeValidation, structErrEmptyDest.Type)
assert.Equal(t, CodeValidationRequired, structErrEmptyDest.Code)
}
})
t.Run("config path validation errors", func(t *testing.T) {
// Test path traversal in config
err := ValidateConfigPath("../../etc/config.yaml")
assert.Error(t, err)
var structErrTraversalInConfig *StructuredError
if errors.As(err, &structErrTraversalInConfig) {
assert.Equal(t, ErrorTypeValidation, structErrTraversalInConfig.Type)
assert.Equal(t, CodeValidationPath, structErrTraversalInConfig.Code)
}
})
}
func TestPathSecurityChecks(t *testing.T) {
// Test various path traversal attempts
traversalPaths := []string{
"../etc/passwd",
"../../root/.ssh/id_rsa",
"/home/../../../etc/shadow",
"./../../sensitive/data",
"foo/../../../bar",
}
for _, path := range traversalPaths {
t.Run("source_"+path, func(t *testing.T) {
err := ValidateSourcePath(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), testPathTraversal)
})
t.Run("dest_"+path, func(t *testing.T) {
err := ValidateDestinationPath(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), testPathTraversal)
})
t.Run("config_"+path, func(t *testing.T) {
err := ValidateConfigPath(path)
assert.Error(t, err)
assert.Contains(t, err.Error(), testPathTraversal)
})
}
}
func TestSpecialPaths(t *testing.T) {
t.Run("GetBaseName with special paths", func(t *testing.T) {
specialPaths := map[string]string{
"/": "/",
"": "output",
".": "output",
"..": "..",
"/.": "output", // filepath.Base("/.") returns "." which matches the output condition
"/..": "..",
"//": "/",
"///": "/",
}
for path, expected := range specialPaths {
result := GetBaseName(path)
assert.Equal(t, expected, result, "Path: %s", path)
}
})
}
func TestPathNormalization(t *testing.T) {
tempDir := t.TempDir()
t.Run("source path normalization", func(t *testing.T) {
// Create nested directory
nestedDir := filepath.Join(tempDir, "a", "b", "c")
require.NoError(t, os.MkdirAll(nestedDir, 0o750))
// Test path with redundant separators
redundantPath := tempDir + string(
os.PathSeparator,
) + string(
os.PathSeparator,
) + "a" + string(
os.PathSeparator,
) + "b" + string(
os.PathSeparator,
) + "c"
err := ValidateSourcePath(redundantPath)
assert.NoError(t, err)
})
}
func TestPathValidationConcurrency(t *testing.T) {
tempDir := t.TempDir()
// Test concurrent path validation
paths := []string{
tempDir,
".",
"/tmp",
}
errChan := make(chan error, len(paths)*2)
for _, path := range paths {
go func(p string) {
errChan <- ValidateSourcePath(p)
}(path)
go func(p string) {
errChan <- ValidateDestinationPath(p + "/output.txt")
}(path)
}
// Collect results
for i := 0; i < len(paths)*2; i++ {
<-errChan
}
// No assertions needed - test passes if no panic/race
}

264
gibidiutils/paths_test.go Normal file
View File

@@ -0,0 +1,264 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGetAbsolutePath(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 := []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: "empty path",
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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.skipWindows && runtime.GOOS == "windows" {
t.Skip("Skipping test on Windows")
}
got, err := GetAbsolutePath(tt.path)
if tt.wantErr {
if err == nil {
t.Errorf("GetAbsolutePath() error = nil, wantErr %v", tt.wantErr)
return
}
if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) {
t.Errorf("GetAbsolutePath() error = %v, want error containing %v", err, tt.wantErrMsg)
}
return
}
if err != nil {
t.Errorf("GetAbsolutePath() unexpected error = %v", err)
return
}
// Clean the expected path for comparison
wantClean := filepath.Clean(tt.wantPrefix)
gotClean := filepath.Clean(got)
if gotClean != wantClean {
t.Errorf("GetAbsolutePath() = %v, want %v", gotClean, wantClean)
}
// Verify the result is actually absolute
if !filepath.IsAbs(got) {
t.Errorf("GetAbsolutePath() returned non-absolute path: %v", got)
}
})
}
}
func TestGetAbsolutePathSpecialCases(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping Unix-specific tests on Windows")
}
tests := []struct {
name string
setup func() (string, func())
path string
wantErr bool
}{
{
name: "symlink to directory",
setup: func() (string, func()) {
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() {}
},
path: "",
wantErr: false,
},
{
name: "broken symlink",
setup: func() (string, func()) {
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() {}
},
path: "",
wantErr: false, // filepath.Abs still works with broken symlinks
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, cleanup := tt.setup()
defer cleanup()
if tt.path == "" {
tt.path = path
}
got, err := GetAbsolutePath(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("GetAbsolutePath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil && !filepath.IsAbs(got) {
t.Errorf("GetAbsolutePath() returned non-absolute path: %v", got)
}
})
}
}
// TestGetAbsolutePathConcurrency verifies that GetAbsolutePath is safe for concurrent use.
// The test intentionally does not use assertions - it will panic if there's a race condition.
// Run with -race flag to detect concurrent access issues.
func TestGetAbsolutePathConcurrency(_ *testing.T) {
// Test that GetAbsolutePath 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) {
_, _ = GetAbsolutePath(path)
done <- true
}(p)
}
// Wait for all goroutines to complete
for range paths {
<-done
}
}
func TestGetAbsolutePathErrorFormatting(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 := GetAbsolutePath(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)
}
}
// BenchmarkGetAbsolutePath benchmarks the GetAbsolutePath function
func BenchmarkGetAbsolutePath(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = GetAbsolutePath("test/path/file.go")
}
}
// BenchmarkGetAbsolutePathAbs benchmarks with already absolute path
func BenchmarkGetAbsolutePathAbs(b *testing.B) {
absPath := "/home/user/test/file.go"
if runtime.GOOS == "windows" {
absPath = "C:\\Users\\test\\file.go"
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = GetAbsolutePath(absPath)
}
}
// BenchmarkGetAbsolutePathCurrent benchmarks with current directory
func BenchmarkGetAbsolutePathCurrent(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = GetAbsolutePath(".")
}
}

View File

@@ -0,0 +1,18 @@
package gibidiutils
// Test constants to avoid duplication in test files.
// These constants are used across multiple test files in the gibidiutils package.
const (
// Error messages
testErrFileNotFound = "file not found"
testErrWriteFailed = "write failed"
testErrInvalidFormat = "invalid format"
// Path validation messages
testEmptyPath = "empty path"
testPathTraversal = "path traversal"
testPathTraversalAttempt = "path traversal attempt"
testPathTraversalDetected = "path traversal attempt detected"
)

162
gibidiutils/writers.go Normal file
View File

@@ -0,0 +1,162 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"encoding/json"
"io"
"math"
"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.
//
//revive:disable-next-line:cognitive-complexity
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 {
processed := buf[:n]
if processChunk != nil {
processed = processChunk(processed)
}
if _, writeErr := writer.Write(processed); writeErr != nil {
wrappedErr := WrapError(writeErr, ErrorTypeIO, CodeIOWrite, "failed to write content chunk")
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
}
if err == io.EOF {
break
}
if err != nil {
wrappedErr := WrapError(err, ErrorTypeIO, CodeIOFileRead, "failed to read content chunk")
if filePath != "" {
wrappedErr = wrappedErr.WithFilePath(filePath)
}
return wrappedErr
}
}
return nil
}
// 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, _ := json.Marshal(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 == "true" || content == "false" ||
content == "null" || content == "~"
if needsQuotes {
// Use double quotes and escape internal quotes
escaped := strings.ReplaceAll(content, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
return "\"" + escaped + "\""
}
return content
}
// SafeUint64ToInt64WithDefault safely converts uint64 to int64, returning a default value if overflow would occur.
// When defaultValue is 0 (the safe default), clamps to MaxInt64 on overflow to keep guardrails active.
// This prevents overflow from making monitors think memory usage is zero when it's actually maxed out.
func SafeUint64ToInt64WithDefault(value uint64, defaultValue int64) int64 {
if value > math.MaxInt64 {
// When caller uses 0 as "safe" default, clamp to max so overflow still trips guardrails
if defaultValue == 0 {
return math.MaxInt64
}
return defaultValue
}
return int64(value) //#nosec G115 -- Safe: value <= MaxInt64 checked above
}
// 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, CodeIOFileRead, "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
}

111
gibidiutils/writers_test.go Normal file
View File

@@ -0,0 +1,111 @@
// Package gibidiutils provides common utility functions for gibidify.
package gibidiutils
import (
"math"
"testing"
)
func TestSafeUint64ToInt64WithDefault(t *testing.T) {
tests := []struct {
name string
value uint64
defaultValue int64
want int64
}{
{
name: "normal value within range",
value: 1000,
defaultValue: 0,
want: 1000,
},
{
name: "zero value",
value: 0,
defaultValue: 0,
want: 0,
},
{
name: "max int64 exactly",
value: math.MaxInt64,
defaultValue: 0,
want: math.MaxInt64,
},
{
name: "overflow with zero default clamps to max",
value: math.MaxInt64 + 1,
defaultValue: 0,
want: math.MaxInt64,
},
{
name: "large overflow with zero default clamps to max",
value: math.MaxUint64,
defaultValue: 0,
want: math.MaxInt64,
},
{
name: "overflow with custom default returns custom",
value: math.MaxInt64 + 1,
defaultValue: -1,
want: -1,
},
{
name: "overflow with custom positive default",
value: math.MaxUint64,
defaultValue: 12345,
want: 12345,
},
{
name: "large value within range",
value: uint64(math.MaxInt64 - 1000),
defaultValue: 0,
want: math.MaxInt64 - 1000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SafeUint64ToInt64WithDefault(tt.value, tt.defaultValue)
if got != tt.want {
t.Errorf("SafeUint64ToInt64WithDefault(%d, %d) = %d, want %d",
tt.value, tt.defaultValue, got, tt.want)
}
})
}
}
func TestSafeUint64ToInt64WithDefaultGuardrailsBehavior(t *testing.T) {
// Test that overflow with default=0 returns MaxInt64, not 0
// This is critical for back-pressure and resource monitors
result := SafeUint64ToInt64WithDefault(math.MaxUint64, 0)
if result == 0 {
t.Error("Overflow with default=0 returned 0, which would disable guardrails")
}
if result != math.MaxInt64 {
t.Errorf("Overflow with default=0 should clamp to MaxInt64, got %d", result)
}
}
// BenchmarkSafeUint64ToInt64WithDefault benchmarks the conversion function
func BenchmarkSafeUint64ToInt64WithDefault(b *testing.B) {
b.Run("normal_value", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = SafeUint64ToInt64WithDefault(1000, 0)
}
})
b.Run("overflow_zero_default", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = SafeUint64ToInt64WithDefault(math.MaxUint64, 0)
}
})
b.Run("overflow_custom_default", func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = SafeUint64ToInt64WithDefault(math.MaxUint64, -1)
}
})
}