feat: many features, check TODO.md

This commit is contained in:
2025-07-19 00:45:21 +03:00
parent 3556b06bb9
commit e35126856d
50 changed files with 6996 additions and 674 deletions

228
utils/errors.go Normal file
View File

@@ -0,0 +1,228 @@
// Package utils provides common utility functions.
package utils
import (
"fmt"
"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"
}
}
// 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 string) *StructuredError {
return &StructuredError{
Type: errorType,
Code: code,
Message: message,
}
}
// 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"
CodeIORead = "READ"
CodeIOClose = "CLOSE"
// Validation Error Codes
CodeValidationFormat = "FORMAT"
CodeValidationFileType = "FILE_TYPE"
CodeValidationSize = "SIZE_LIMIT"
)
// Predefined error constructors for common error scenarios
// NewCLIMissingSourceError creates a CLI error for missing source argument.
func NewCLIMissingSourceError() *StructuredError {
return NewStructuredError(ErrorTypeCLI, CodeCLIMissingSource, "usage: gibidify -source <source_directory> [--destination <output_file>] [--format=json|yaml|markdown]")
}
// NewFileSystemError creates a file system error.
func NewFileSystemError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeFileSystem, code, message)
}
// NewProcessingError creates a processing error.
func NewProcessingError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeProcessing, code, message)
}
// NewIOError creates an IO error.
func NewIOError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeIO, code, message)
}
// NewValidationError creates a validation error.
func NewValidationError(code, message string) *StructuredError {
return NewStructuredError(ErrorTypeValidation, code, message)
}
// 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
if structErr, ok := err.(*StructuredError); ok {
logrus.WithFields(logrus.Fields{
"error_type": structErr.Type.String(),
"error_code": structErr.Code,
"context": structErr.Context,
"file_path": structErr.FilePath,
"line": structErr.Line,
}).Errorf("%s: %v", msg, err)
} else {
logrus.Errorf("%s: %v", 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...)
}
}

242
utils/errors_test.go Normal file
View File

@@ -0,0 +1,242 @@
package utils
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(t *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(t *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)
}
}

26
utils/paths.go Normal file
View File

@@ -0,0 +1,26 @@
// Package utils provides common utility functions.
package utils
import (
"fmt"
"path/filepath"
)
// 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
}

262
utils/paths_test.go Normal file
View File

@@ -0,0 +1,262 @@
package utils
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, 0o755); 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)
}
})
}
}
func TestGetAbsolutePathConcurrency(t *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 {
// Normal case - just verify we got a valid absolute path
if !filepath.IsAbs(got) {
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(".")
}
}